From c20ee3476d7db58cd69cfd98e05f534128cad4c8 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 11 Sep 2025 13:18:22 +0200 Subject: [PATCH 1/7] XC-412: Add support for `eth_sendRawTransaction` --- evm_rpc_client/src/lib.rs | 43 +++++++++++++++++++++++++++++-- evm_rpc_client/src/request/mod.rs | 35 +++++++++++++++++++++++++ evm_rpc_types/src/result/alloy.rs | 27 ++++++++++++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index ec823340..ce00f031 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -110,12 +110,13 @@ mod runtime; use crate::request::{ CallRequest, CallRequestBuilder, FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetTransactionCountRequest, - GetTransactionCountRequestBuilder, Request, RequestBuilder, + GetTransactionCountRequestBuilder, Request, RequestBuilder, SendRawTransactionRequest, + SendRawTransactionRequestBuilder, }; use candid::{CandidType, Principal}; use evm_rpc_types::{ BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, - RpcConfig, RpcServices, + Hex, RpcConfig, RpcServices, }; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; @@ -553,6 +554,44 @@ impl EvmRpcClient { 10_000_000_000, ) } + + /// Call `eth_sendRawTransaction` on the EVM RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use alloy_primitives::{b256, bytes}; + /// use evm_rpc_client::EvmRpcClient; + /// + /// # use evm_rpc_types::{MultiRpcResult, Hex32}; + /// # use std::str::FromStr; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// use evm_rpc_types::SendRawTransactionStatus; + /// let client = EvmRpcClient::builder_for_ic() + /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(SendRawTransactionStatus::Ok(Some(Hex32::from_str("0x5f1d3e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d").unwrap()))))) + /// .build(); + /// + /// let result = client + /// .send_raw_transaction(bytes!("0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675")) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!(result, Ok(Some(b256!("0x5f1d3e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d")))); + /// # Ok(()) + /// # } + /// ``` + pub fn send_raw_transaction( + &self, + params: impl Into, + ) -> SendRawTransactionRequestBuilder { + RequestBuilder::new( + self.clone(), + SendRawTransactionRequest::new(params.into()), + 10_000_000_000, + ) + } } impl EvmRpcClient { diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 59a7ba9f..7c45eb82 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -242,6 +242,38 @@ impl GetTransactionCountRequestBuilder { } } +#[derive(Debug, Clone)] +pub struct SendRawTransactionRequest(Hex); + +impl SendRawTransactionRequest { + pub fn new(params: Hex) -> Self { + Self(params) + } +} + +impl EvmRpcRequest for SendRawTransactionRequest { + type Config = RpcConfig; + type Params = Hex; + type CandidOutput = MultiRpcResult; + type Output = MultiRpcResult>; + + fn endpoint(&self) -> EvmRpcEndpoint { + EvmRpcEndpoint::SendRawTransaction + } + + fn params(self) -> Self::Params { + self.0 + } +} + +pub type SendRawTransactionRequestBuilder = RequestBuilder< + R, + RpcConfig, + Hex, + MultiRpcResult, + MultiRpcResult>, +>; + /// Ethereum RPC endpoint supported by the EVM RPC canister. pub trait EvmRpcRequest { /// Type of RPC config for that request. @@ -273,6 +305,8 @@ pub enum EvmRpcEndpoint { GetLogs, /// `eth_getTransactionCount` endpoint. GetTransactionCount, + /// `eth_sendRawTransaction` endpoint. + SendRawTransaction, } impl EvmRpcEndpoint { @@ -284,6 +318,7 @@ impl EvmRpcEndpoint { Self::GetBlockByNumber => "eth_getBlockByNumber", Self::GetLogs => "eth_getLogs", Self::GetTransactionCount => "eth_getTransactionCount", + Self::SendRawTransaction => "eth_sendRawTransaction", } } } diff --git a/evm_rpc_types/src/result/alloy.rs b/evm_rpc_types/src/result/alloy.rs index 01e78c84..cfc71f61 100644 --- a/evm_rpc_types/src/result/alloy.rs +++ b/evm_rpc_types/src/result/alloy.rs @@ -1,4 +1,7 @@ -use crate::{Block, FeeHistory, Hex, LogEntry, MultiRpcResult, Nat256}; +use crate::{ + Block, FeeHistory, Hex, JsonRpcError, LogEntry, MultiRpcResult, Nat256, RpcError, + SendRawTransactionStatus, +}; impl From>> for MultiRpcResult> { fn from(result: MultiRpcResult>) -> Self { @@ -33,3 +36,25 @@ impl From> for MultiRpcResult { result.map(alloy_primitives::Bytes::from) } } + +impl From> + for MultiRpcResult> +{ + fn from(result: MultiRpcResult) -> Self { + result.and_then(|status| match status { + SendRawTransactionStatus::Ok(maybe_hash) => { + Ok(maybe_hash.map(alloy_primitives::B256::from)) + } + error => Err(RpcError::JsonRpcError(JsonRpcError { + code: -32_000, + message: match error { + SendRawTransactionStatus::Ok(_) => unreachable!(), + SendRawTransactionStatus::InsufficientFunds => "Insufficient funds", + SendRawTransactionStatus::NonceTooLow => "Nonce too low", + SendRawTransactionStatus::NonceTooHigh => "Nonce too high", + } + .to_string(), + })), + }) + } +} From a3d81d35470a34e595f86daae5a5a298d532fd08 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 11 Sep 2025 13:49:34 +0200 Subject: [PATCH 2/7] XC-412: Integration tests --- evm_rpc_client/src/lib.rs | 6 +- tests/tests.rs | 229 +++++++++++++++++++++----------------- 2 files changed, 127 insertions(+), 108 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index ce00f031..e3bc854c 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -569,16 +569,16 @@ impl EvmRpcClient { /// # async fn main() -> Result<(), Box> { /// use evm_rpc_types::SendRawTransactionStatus; /// let client = EvmRpcClient::builder_for_ic() - /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(SendRawTransactionStatus::Ok(Some(Hex32::from_str("0x5f1d3e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d").unwrap()))))) + /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(SendRawTransactionStatus::Ok(Some(Hex32::from_str("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788").unwrap()))))) /// .build(); /// /// let result = client - /// .send_raw_transaction(bytes!("0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675")) + /// .send_raw_transaction(bytes!("0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83")) /// .send() /// .await /// .expect_consistent(); /// - /// assert_eq!(result, Ok(Some(b256!("0x5f1d3e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d")))); + /// assert_eq!(result, Ok(Some(b256!("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788")))); /// # Ok(()) /// # } /// ``` diff --git a/tests/tests.rs b/tests/tests.rs index 3f057ea6..581ec756 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -10,7 +10,7 @@ use crate::{ }, setup::EvmRpcNonblockingSetup, }; -use alloy_primitives::{address, b256, bloom, bytes, U256}; +use alloy_primitives::{address, b256, bloom, bytes, Bytes, B256, U256}; use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; use candid::{CandidType, Decode, Encode, Nat, Principal}; @@ -23,10 +23,9 @@ use evm_rpc::{ types::{Metrics, ProviderId, RpcAccess, RpcMethod}, }; use evm_rpc_types::{ - BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, - Hex20, Hex32, HttpOutcallError, InstallArgs, JsonRpcError, LegacyRejectionCode, MultiRpcResult, - Nat256, Provider, ProviderError, RpcApi, RpcError, RpcResult, RpcService, RpcServices, - ValidationError, + BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex20, + HttpOutcallError, InstallArgs, JsonRpcError, LegacyRejectionCode, MultiRpcResult, Nat256, + Provider, ProviderError, RpcApi, RpcError, RpcResult, RpcService, RpcServices, ValidationError, }; use ic_cdk::api::management_canister::main::CanisterId; use ic_error_types::RejectCode; @@ -57,9 +56,9 @@ const MOCK_REQUEST_RESPONSE: &str = r#"{"jsonrpc":"2.0","id":1,"result":"0x00112 const MOCK_REQUEST_RESPONSE_BYTES: u64 = 1000; const MOCK_API_KEY: &str = "mock-api-key"; -const MOCK_TRANSACTION: &str = "0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; -const MOCK_TRANSACTION_HASH: &str = - "0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788"; +const MOCK_TRANSACTION: Bytes = bytes!("0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"); +const MOCK_TRANSACTION_HASH: B256 = + b256!("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788"); const RPC_SERVICES: &[RpcServices] = &[ RpcServices::EthMainnet(None), @@ -254,19 +253,6 @@ impl EvmRpcSetup { ) } - pub fn eth_send_raw_transaction( - &self, - source: RpcServices, - config: Option, - signed_raw_transaction_hex: &str, - ) -> CallFlow> { - let signed_raw_transaction_hex: Hex = signed_raw_transaction_hex.parse().unwrap(); - self.call_update( - "eth_sendRawTransaction", - Encode!(&source, &config, &signed_raw_transaction_hex).unwrap(), - ) - } - pub fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { self.call_update("updateApiKeys", Encode!(&api_keys).unwrap()) .wait() @@ -1208,26 +1194,38 @@ async fn eth_fee_history_should_succeed() { } } -#[test] -fn eth_send_raw_transaction_should_succeed() { - let [response_0, response_1, response_2] = - json_rpc_sequential_id(json!({"id":0,"jsonrpc":"2.0","result":"Ok"})); - for source in RPC_SERVICES { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn eth_send_raw_transaction_should_succeed() { + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") + .with_params(json!([MOCK_TRANSACTION.to_string()])) + } + + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" })) + } + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(offset)) + .respond_with(mock_response().with_id(offset)) + .given(mock_request().with_id(1 + offset)) + .respond_with(mock_response().with_id(1 + offset)) + .given(mock_request().with_id(2 + offset)) + .respond_with(mock_response().with_id(2 + offset)); + let response = setup - .eth_send_raw_transaction(source.clone(), None, MOCK_TRANSACTION) - .mock_http_once(MockOutcallBuilder::new(200, response_0.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_1.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_2.clone())) - .wait() + .client(mocks) + .with_rpc_sources(source.clone()) + .build() + .send_raw_transaction(MOCK_TRANSACTION) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!( - response, - evm_rpc_types::SendRawTransactionStatus::Ok(Some( - Hex32::from_str(MOCK_TRANSACTION_HASH).unwrap() - )) - ); + + assert_eq!(response, Some(MOCK_TRANSACTION_HASH)); } } @@ -1506,46 +1504,54 @@ fn candid_rpc_should_reject_empty_service_list() { ); } -#[test] -fn candid_rpc_should_return_inconsistent_results() { - let setup = EvmRpcSetup::new().mock_api_keys(); - let results = setup - .eth_send_raw_transaction( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Ankr, - EthMainnetService::Cloudflare, - ])), - None, - MOCK_TRANSACTION, - ) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"id":0,"jsonrpc":"2.0","result":"Ok"}"#, - )) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"id":1,"jsonrpc":"2.0","result":"NonceTooLow"}"#, +#[tokio::test] +async fn candid_rpc_should_return_inconsistent_results() { + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") + .with_params(json!([MOCK_TRANSACTION.to_string()])) + } + + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(0_u64)) + .respond_with(JsonRpcResponse::from( + json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" }), )) - .wait() + .given(mock_request().with_id(1_u64)) + .respond_with(JsonRpcResponse::from( + json!({ "id": 1, "jsonrpc": "2.0", "result": "NonceTooLow" }), + )); + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + let results = setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Ankr, + EthMainnetService::Cloudflare, + ]))) + .build() + .send_raw_transaction(MOCK_TRANSACTION) + .send() + .await .expect_inconsistent(); assert_eq!( results, vec![ ( RpcService::EthMainnet(EthMainnetService::Ankr), - Ok(evm_rpc_types::SendRawTransactionStatus::Ok(Some( - Hex32::from_str(MOCK_TRANSACTION_HASH).unwrap() - ))) + Ok(Some(MOCK_TRANSACTION_HASH)) ), ( RpcService::EthMainnet(EthMainnetService::Cloudflare), - Ok(evm_rpc_types::SendRawTransactionStatus::NonceTooLow) + Err(RpcError::JsonRpcError(JsonRpcError { + code: -32_000, + message: "Nonce too low".to_string() + })) ) ] ); let rpc_method = || RpcMethod::EthSendRawTransaction.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ANKR_HOSTNAME.into()) => 1, @@ -1908,37 +1914,39 @@ async fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_stat ); } -#[test] -fn candid_rpc_should_handle_already_known() { - let setup = EvmRpcSetup::new().mock_api_keys(); - let result = setup - .eth_send_raw_transaction( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Ankr, - EthMainnetService::Cloudflare, - ])), - None, - MOCK_TRANSACTION, - ) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"id":0,"jsonrpc":"2.0","result":"Ok"}"#, - )) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"id":1,"jsonrpc":"2.0","error":{"code":-32000,"message":"already known"}}"#, +#[tokio::test] +async fn candid_rpc_should_handle_already_known() { + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") + .with_params(json!([MOCK_TRANSACTION.to_string()])) + } + + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(0_u64)) + .respond_with(JsonRpcResponse::from( + json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" }), )) - .wait() + .given(mock_request().with_id(1_u64)) + .respond_with(JsonRpcResponse::from( + json!({ "id": 1, "jsonrpc": "2.0", "error": {"code": -32000, "message": "already known"} }), + )); + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + let result = setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Ankr, + EthMainnetService::Cloudflare, + ]))) + .build() + .send_raw_transaction(MOCK_TRANSACTION) + .send() + .await .expect_consistent(); - assert_eq!( - result, - Ok(evm_rpc_types::SendRawTransactionStatus::Ok(Some( - Hex32::from_str(MOCK_TRANSACTION_HASH).unwrap() - ))) - ); + assert_eq!(result, Ok(Some(MOCK_TRANSACTION_HASH))); let rpc_method = || RpcMethod::EthSendRawTransaction.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ANKR_HOSTNAME.into()) => 1, @@ -1953,34 +1961,45 @@ fn candid_rpc_should_handle_already_known() { ); } -#[test] -fn candid_rpc_should_recognize_rate_limit() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn candid_rpc_should_recognize_rate_limit() { + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") + .with_params(json!([MOCK_TRANSACTION.to_string()])) + } + + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(0_u64)) + .respond_with(CanisterHttpReply::with_status(429).with_body("(Rate limit error message)")) + .given(mock_request().with_id(1_u64)) + .respond_with(CanisterHttpReply::with_status(429).with_body("(Rate limit error message)")); + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let result = setup - .eth_send_raw_transaction( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Ankr, - EthMainnetService::Cloudflare, - ])), - None, - MOCK_TRANSACTION, - ) - .mock_http(MockOutcallBuilder::new(429, "(Rate limit error message)")) - .wait() + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Ankr, + EthMainnetService::Cloudflare, + ]))) + .build() + .send_raw_transaction(MOCK_TRANSACTION) + .send() + .await .expect_consistent(); + assert_eq!( result, Err(RpcError::HttpOutcallError( HttpOutcallError::InvalidHttpJsonRpcResponse { status: 429, - body: "(Rate limit error message)".to_string(), + body: "\"(Rate limit error message)\"".to_string(), parsing_error: None } )) ); let rpc_method = || RpcMethod::EthSendRawTransaction.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ANKR_HOSTNAME.into()) => 1, From 0db5cc2d4f08f364072b11c1584c419961560f75 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 11 Sep 2025 13:59:14 +0200 Subject: [PATCH 3/7] XC-412: Clean-up `mock_request` methods --- tests/tests.rs | 109 ++++++++++++++++++------------------------------- 1 file changed, 39 insertions(+), 70 deletions(-) diff --git a/tests/tests.rs b/tests/tests.rs index 581ec756..028a9de7 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -770,11 +770,6 @@ async fn eth_get_logs_should_fail_when_block_range_too_large() { #[tokio::test] async fn eth_get_block_by_number_should_succeed() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getBlockByNumber") - .with_params(json!(["latest", false])) - } - fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "jsonrpc": "2.0", @@ -807,11 +802,11 @@ async fn eth_get_block_by_number_should_succeed() { for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(offset)) + .given(get_block_by_number_request().with_id(offset)) .respond_with(mock_response().with_id(offset)) - .given(mock_request().with_id(1 + offset)) + .given(get_block_by_number_request().with_id(1 + offset)) .respond_with(mock_response().with_id(1 + offset)) - .given(mock_request().with_id(2 + offset)) + .given(get_block_by_number_request().with_id(2 + offset)) .respond_with(mock_response().with_id(2 + offset)); let response = setup @@ -862,11 +857,6 @@ async fn eth_get_block_by_number_should_succeed() { #[tokio::test] async fn eth_get_block_by_number_pre_london_fork_should_succeed() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getBlockByNumber") - .with_params(json!(["latest", false])) - } - fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "jsonrpc":"2.0", @@ -900,11 +890,11 @@ async fn eth_get_block_by_number_pre_london_fork_should_succeed() { for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(offset)) + .given(get_block_by_number_request().with_id(offset)) .respond_with(mock_response().with_id(offset)) - .given(mock_request().with_id(1 + offset)) + .given(get_block_by_number_request().with_id(1 + offset)) .respond_with(mock_response().with_id(1 + offset)) - .given(mock_request().with_id(2 + offset)) + .given(get_block_by_number_request().with_id(2 + offset)) .respond_with(mock_response().with_id(2 + offset)); let response = setup @@ -955,11 +945,6 @@ async fn eth_get_block_by_number_pre_london_fork_should_succeed() { #[tokio::test] async fn eth_get_block_by_number_should_be_consistent_when_total_difficulty_inconsistent() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getBlockByNumber") - .with_params(json!(["latest", false])) - } - fn mock_response(total_difficulty: Option<&str>) -> JsonRpcResponse { let mut body = json!({ "jsonrpc":"2.0", @@ -996,9 +981,9 @@ async fn eth_get_block_by_number_should_be_consistent_when_total_difficulty_inco let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(0_u64)) + .given(get_block_by_number_request().with_id(0_u64)) .respond_with(mock_response(Some("0xc70d815d562d3cfa955")).with_id(0_u64)) - .given(mock_request().with_id(1_u64)) + .given(get_block_by_number_request().with_id(1_u64)) .respond_with(mock_response(None).with_id(1_u64)); let response = setup @@ -1139,14 +1124,6 @@ async fn eth_get_transaction_count_should_succeed() { #[tokio::test] async fn eth_fee_history_should_succeed() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_feeHistory").with_params(json!([ - "0x3", - "latest", - [] - ])) - } - fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "id" : 0, @@ -1163,11 +1140,11 @@ async fn eth_fee_history_should_succeed() { for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(offset)) + .given(fee_history_request().with_id(offset)) .respond_with(mock_response().with_id(offset)) - .given(mock_request().with_id(1 + offset)) + .given(fee_history_request().with_id(1 + offset)) .respond_with(mock_response().with_id(1 + offset)) - .given(mock_request().with_id(2 + offset)) + .given(fee_history_request().with_id(2 + offset)) .respond_with(mock_response().with_id(2 + offset)); let response = setup @@ -1196,11 +1173,6 @@ async fn eth_fee_history_should_succeed() { #[tokio::test] async fn eth_send_raw_transaction_should_succeed() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") - .with_params(json!([MOCK_TRANSACTION.to_string()])) - } - fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" })) } @@ -1208,11 +1180,11 @@ async fn eth_send_raw_transaction_should_succeed() { let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(offset)) + .given(send_raw_transaction_request().with_id(offset)) .respond_with(mock_response().with_id(offset)) - .given(mock_request().with_id(1 + offset)) + .given(send_raw_transaction_request().with_id(1 + offset)) .respond_with(mock_response().with_id(1 + offset)) - .given(mock_request().with_id(2 + offset)) + .given(send_raw_transaction_request().with_id(2 + offset)) .respond_with(mock_response().with_id(2 + offset)); let response = setup @@ -1506,17 +1478,12 @@ fn candid_rpc_should_reject_empty_service_list() { #[tokio::test] async fn candid_rpc_should_return_inconsistent_results() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") - .with_params(json!([MOCK_TRANSACTION.to_string()])) - } - let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(0_u64)) + .given(send_raw_transaction_request().with_id(0_u64)) .respond_with(JsonRpcResponse::from( json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" }), )) - .given(mock_request().with_id(1_u64)) + .given(send_raw_transaction_request().with_id(1_u64)) .respond_with(JsonRpcResponse::from( json!({ "id": 1, "jsonrpc": "2.0", "result": "NonceTooLow" }), )); @@ -1916,17 +1883,12 @@ async fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_stat #[tokio::test] async fn candid_rpc_should_handle_already_known() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") - .with_params(json!([MOCK_TRANSACTION.to_string()])) - } - let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(0_u64)) + .given(send_raw_transaction_request().with_id(0_u64)) .respond_with(JsonRpcResponse::from( json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" }), )) - .given(mock_request().with_id(1_u64)) + .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"} }), )); @@ -1963,15 +1925,10 @@ async fn candid_rpc_should_handle_already_known() { #[tokio::test] async fn candid_rpc_should_recognize_rate_limit() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") - .with_params(json!([MOCK_TRANSACTION.to_string()])) - } - let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(0_u64)) + .given(send_raw_transaction_request().with_id(0_u64)) .respond_with(CanisterHttpReply::with_status(429).with_body("(Rate limit error message)")) - .given(mock_request().with_id(1_u64)) + .given(send_raw_transaction_request().with_id(1_u64)) .respond_with(CanisterHttpReply::with_status(429).with_body("(Rate limit error message)")); let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; @@ -2623,12 +2580,6 @@ async fn should_fail_when_response_id_inconsistent_with_request_id() { #[tokio::test] async fn should_log_request() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_feeHistory") - .with_params(json!(["0x3", "latest", []])) - .with_id(0_u64) - } - fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "id" : 0, @@ -2644,7 +2595,7 @@ async fn should_log_request() { let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request()) + .given(fee_history_request()) .respond_with(mock_response()); let response = setup @@ -2790,6 +2741,24 @@ async fn should_change_default_provider_when_one_keeps_failing() { assert_eq!(response, U256::ONE); } +fn get_block_by_number_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getBlockByNumber") + .with_params(json!(["latest", false])) + .with_id(0_u64) +} + +fn fee_history_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_feeHistory") + .with_params(json!(["0x3", "latest", []])) + .with_id(0_u64) +} + +fn send_raw_transaction_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_sendRawTransaction") + .with_params(json!([MOCK_TRANSACTION.to_string()])) + .with_id(0_u64) +} + fn get_transaction_count_request() -> JsonRpcRequestMatcher { JsonRpcRequestMatcher::with_method("eth_getTransactionCount") .with_params(json!([ From 0177a4e59d2fcc5bce0587ce481be9bc79f902b4 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 22 Sep 2025 16:04:57 +0200 Subject: [PATCH 4/7] XC-412: Hide `use` statement in doc --- evm_rpc_client/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index e3bc854c..f1906e7f 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -563,11 +563,10 @@ impl EvmRpcClient { /// use alloy_primitives::{b256, bytes}; /// use evm_rpc_client::EvmRpcClient; /// - /// # use evm_rpc_types::{MultiRpcResult, Hex32}; + /// # use evm_rpc_types::{MultiRpcResult, Hex32, SendRawTransactionStatus}; /// # use std::str::FromStr; /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { - /// use evm_rpc_types::SendRawTransactionStatus; /// let client = EvmRpcClient::builder_for_ic() /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(SendRawTransactionStatus::Ok(Some(Hex32::from_str("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788").unwrap()))))) /// .build(); From 9a5621008b334a94d718bcb57033a42e987dc7fa Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 22 Sep 2025 16:19:36 +0200 Subject: [PATCH 5/7] XC-412: Return `B256` instead of `Option` --- evm_rpc_client/src/lib.rs | 2 +- evm_rpc_client/src/request/mod.rs | 4 ++-- evm_rpc_types/src/result/alloy.rs | 15 ++++++++------- tests/tests.rs | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index f1906e7f..cfe19ac0 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -577,7 +577,7 @@ impl EvmRpcClient { /// .await /// .expect_consistent(); /// - /// assert_eq!(result, Ok(Some(b256!("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788")))); + /// assert_eq!(result, Ok(b256!("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788"))); /// # Ok(()) /// # } /// ``` diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 7c45eb82..2ed9df57 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -255,7 +255,7 @@ impl EvmRpcRequest for SendRawTransactionRequest { type Config = RpcConfig; type Params = Hex; type CandidOutput = MultiRpcResult; - type Output = MultiRpcResult>; + type Output = MultiRpcResult; fn endpoint(&self) -> EvmRpcEndpoint { EvmRpcEndpoint::SendRawTransaction @@ -271,7 +271,7 @@ pub type SendRawTransactionRequestBuilder = RequestBuilder< RpcConfig, Hex, MultiRpcResult, - MultiRpcResult>, + MultiRpcResult, >; /// Ethereum RPC endpoint supported by the EVM RPC canister. diff --git a/evm_rpc_types/src/result/alloy.rs b/evm_rpc_types/src/result/alloy.rs index cfc71f61..f8581c51 100644 --- a/evm_rpc_types/src/result/alloy.rs +++ b/evm_rpc_types/src/result/alloy.rs @@ -1,6 +1,6 @@ use crate::{ Block, FeeHistory, Hex, JsonRpcError, LogEntry, MultiRpcResult, Nat256, RpcError, - SendRawTransactionStatus, + SendRawTransactionStatus, ValidationError, }; impl From>> for MultiRpcResult> { @@ -37,14 +37,15 @@ impl From> for MultiRpcResult { } } -impl From> - for MultiRpcResult> -{ +impl From> for MultiRpcResult { fn from(result: MultiRpcResult) -> Self { result.and_then(|status| match status { - SendRawTransactionStatus::Ok(maybe_hash) => { - Ok(maybe_hash.map(alloy_primitives::B256::from)) - } + SendRawTransactionStatus::Ok(maybe_hash) => match maybe_hash { + Some(hash) => Ok(alloy_primitives::B256::from(hash)), + None => Err(RpcError::ValidationError(ValidationError::Custom( + "Unable to compute transaction hash".to_string(), + ))), + }, error => Err(RpcError::JsonRpcError(JsonRpcError { code: -32_000, message: match error { diff --git a/tests/tests.rs b/tests/tests.rs index 028a9de7..8f4b7992 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1197,7 +1197,7 @@ async fn eth_send_raw_transaction_should_succeed() { .expect_consistent() .unwrap(); - assert_eq!(response, Some(MOCK_TRANSACTION_HASH)); + assert_eq!(response, MOCK_TRANSACTION_HASH); } } @@ -1505,7 +1505,7 @@ async fn candid_rpc_should_return_inconsistent_results() { vec![ ( RpcService::EthMainnet(EthMainnetService::Ankr), - Ok(Some(MOCK_TRANSACTION_HASH)) + Ok(MOCK_TRANSACTION_HASH) ), ( RpcService::EthMainnet(EthMainnetService::Cloudflare), @@ -1905,7 +1905,7 @@ async fn candid_rpc_should_handle_already_known() { .send() .await .expect_consistent(); - assert_eq!(result, Ok(Some(MOCK_TRANSACTION_HASH))); + assert_eq!(result, Ok(MOCK_TRANSACTION_HASH)); let rpc_method = || RpcMethod::EthSendRawTransaction.into(); assert_eq!( setup.get_metrics().await, From a45ff3bf400f9b3255ec9660ae5ac47b7544826c Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 16 Sep 2025 11:34:45 +0200 Subject: [PATCH 6/7] XC-412: Add client support for `eth_getTransactionReceipt` --- evm_rpc_client/src/lib.rs | 60 ++- evm_rpc_client/src/request/mod.rs | 35 ++ evm_rpc_types/src/lib.rs | 6 + evm_rpc_types/src/response/alloy.rs | 98 +++- evm_rpc_types/src/result/alloy.rs | 14 +- tests/mock_http_runtime/mock/json/mod.rs | 1 + tests/tests.rs | 570 ++++++++++++++--------- 7 files changed, 563 insertions(+), 221 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index cfe19ac0..1fbcb278 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -110,13 +110,14 @@ mod runtime; use crate::request::{ CallRequest, CallRequestBuilder, FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetTransactionCountRequest, - GetTransactionCountRequestBuilder, Request, RequestBuilder, SendRawTransactionRequest, + GetTransactionCountRequestBuilder, GetTransactionReceiptRequest, + GetTransactionReceiptRequestBuilder, Request, RequestBuilder, SendRawTransactionRequest, SendRawTransactionRequestBuilder, }; use candid::{CandidType, Principal}; use evm_rpc_types::{ BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, - Hex, RpcConfig, RpcServices, + Hex, Hex32, RpcConfig, RpcServices, }; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; @@ -555,6 +556,61 @@ impl EvmRpcClient { ) } + /// Call `eth_getTransactionReceipt` on the EVM RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use alloy_primitives::b256; + /// use evm_rpc_client::EvmRpcClient; + /// + /// # use evm_rpc_types::{Hex20, Hex32, Hex256, HexByte, MultiRpcResult, Nat256}; + /// # use std::str::FromStr; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = EvmRpcClient::builder_for_ic() + /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(evm_rpc_types::TransactionReceipt { + /// # block_hash: Hex32::from_str("0xf6084155ff2022773b22df3217d16e9df53cbc42689b27ca4789e06b6339beb2").unwrap(), + /// # block_number: Nat256::from(0x52a975_u64), + /// # effective_gas_price: Nat256::from(0x6052340_u64), + /// # gas_used: Nat256::from(0x1308c_u64), + /// # cumulative_gas_used: Nat256::from(0x797db0_u64), + /// # status: Some(Nat256::from(0x1_u8)), + /// # root: None, + /// # transaction_hash: Hex32::from_str("0xa3ece39ae137617669c6933b7578b94e705e765683f260fcfe30eaa41932610f").unwrap(), + /// # contract_address: None, + /// # from: Hex20::from_str("0xd907941c8b3b966546fc408b8c942eb10a4f98df").unwrap(), + /// # // This receipt contains some transactions, but they are left out here since not asserted in the doctest + /// # logs: vec![], + /// # logs_bloom: Hex256::from_str("0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000020000000000000000000800000000000000004010000010100000000000000000000000000000000000000000000000000040000080000000000000080000000000000000000000000000000000000000000020000000000000000000000002000000000000000000000000000000000000000000000000000020000000010000000000000000000000000000000000000000000000000000000000").unwrap(), + /// # to: Some(Hex20::from_str("0xd6df5935cd03a768b7b9e92637a01b25e24cb709").unwrap()), + /// # transaction_index: Nat256::from(0x29_u64), + /// # tx_type: HexByte::from(0x0_u8), + /// # }))) + /// .build(); + /// + /// let result = client + /// .get_transaction_receipt(b256!("0xa3ece39ae137617669c6933b7578b94e705e765683f260fcfe30eaa41932610f")) + /// .send() + /// .await + /// .expect_consistent() + /// .unwrap(); + /// + /// assert!(result.unwrap().status()); + /// # Ok(()) + /// # } + /// ``` + pub fn get_transaction_receipt( + &self, + params: impl Into, + ) -> GetTransactionReceiptRequestBuilder { + RequestBuilder::new( + self.clone(), + GetTransactionReceiptRequest::new(params.into()), + 10_000_000_000, + ) + } + /// Call `eth_sendRawTransaction` on the EVM RPC canister. /// /// # Examples diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 2ed9df57..ceaf108f 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -242,6 +242,38 @@ impl GetTransactionCountRequestBuilder { } } +#[derive(Debug, Clone)] +pub struct GetTransactionReceiptRequest(Hex32); + +impl GetTransactionReceiptRequest { + pub fn new(params: Hex32) -> Self { + Self(params) + } +} + +impl EvmRpcRequest for GetTransactionReceiptRequest { + type Config = RpcConfig; + type Params = Hex32; + type CandidOutput = MultiRpcResult>; + type Output = MultiRpcResult>; + + fn endpoint(&self) -> EvmRpcEndpoint { + EvmRpcEndpoint::GetTransactionReceipt + } + + fn params(self) -> Self::Params { + self.0 + } +} + +pub type GetTransactionReceiptRequestBuilder = RequestBuilder< + R, + RpcConfig, + Hex32, + MultiRpcResult>, + MultiRpcResult>, +>; + #[derive(Debug, Clone)] pub struct SendRawTransactionRequest(Hex); @@ -305,6 +337,8 @@ pub enum EvmRpcEndpoint { GetLogs, /// `eth_getTransactionCount` endpoint. GetTransactionCount, + /// `eth_getTransactionReceipt` endpoint. + GetTransactionReceipt, /// `eth_sendRawTransaction` endpoint. SendRawTransaction, } @@ -318,6 +352,7 @@ impl EvmRpcEndpoint { Self::GetBlockByNumber => "eth_getBlockByNumber", Self::GetLogs => "eth_getLogs", Self::GetTransactionCount => "eth_getTransactionCount", + Self::GetTransactionReceipt => "eth_getTransactionReceipt", Self::SendRawTransaction => "eth_sendRawTransaction", } } diff --git a/evm_rpc_types/src/lib.rs b/evm_rpc_types/src/lib.rs index 8a6192c5..d639da55 100644 --- a/evm_rpc_types/src/lib.rs +++ b/evm_rpc_types/src/lib.rs @@ -215,6 +215,12 @@ impl_hex_string!(Hex32([u8; 32])); impl_hex_string!(Hex256([u8; 256])); impl_hex_string!(Hex(Vec)); +impl HexByte { + pub fn into_byte(self) -> u8 { + self.0.into_byte() + } +} + impl Hex20 { pub fn as_array(&self) -> &[u8; 20] { &self.0 diff --git a/evm_rpc_types/src/response/alloy.rs b/evm_rpc_types/src/response/alloy.rs index bec17219..cf1d60ba 100644 --- a/evm_rpc_types/src/response/alloy.rs +++ b/evm_rpc_types/src/response/alloy.rs @@ -1,5 +1,8 @@ -use crate::{Block, FeeHistory, Hex32, LogEntry, Nat256, RpcError, ValidationError}; -use alloy_primitives::{B256, U256}; +use crate::{ + Block, FeeHistory, Hex32, HexByte, LogEntry, Nat256, RpcError, RpcResult, TransactionReceipt, + ValidationError, +}; +use alloy_primitives::{Address, B256, U256}; use alloy_rpc_types::BlockTransactions; use candid::Nat; use num_bigint::BigUint; @@ -127,19 +130,106 @@ impl TryFrom for alloy_rpc_types::FeeHistory { } } +impl TryFrom for alloy_rpc_types::TransactionReceipt { + type Error = RpcError; + + fn try_from(receipt: TransactionReceipt) -> Result { + Ok(Self { + inner: alloy_consensus::ReceiptEnvelope::from_typed( + alloy_consensus::TxType::try_from(receipt.tx_type)?, + alloy_consensus::ReceiptWithBloom { + receipt: alloy_consensus::Receipt { + status: validate_receipt_status( + &receipt.block_number, + receipt.root, + receipt.status, + )?, + cumulative_gas_used: try_from_nat256( + receipt.cumulative_gas_used, + "cumulative_gas_used", + )?, + logs: receipt + .logs + .into_iter() + .map(alloy_rpc_types::Log::try_from) + .collect::>>()?, + }, + logs_bloom: alloy_primitives::Bloom::from(receipt.logs_bloom), + }, + ), + transaction_hash: B256::from(receipt.transaction_hash), + transaction_index: Some(try_from_nat256( + receipt.transaction_index, + "transaction_index", + )?), + block_hash: Some(B256::from(receipt.block_hash)), + block_number: Some(try_from_nat256(receipt.block_number, "block_number")?), + gas_used: try_from_nat256(receipt.gas_used, "gas_used")?, + effective_gas_price: try_from_nat256( + receipt.effective_gas_price, + "effective_gas_price", + )?, + blob_gas_used: None, + blob_gas_price: None, + from: Address::from(receipt.from), + to: receipt.to.map(Address::from), + contract_address: receipt.contract_address.map(Address::from), + }) + } +} + +impl TryFrom for alloy_consensus::TxType { + type Error = RpcError; + + fn try_from(value: HexByte) -> Result { + alloy_consensus::TxType::try_from(value.into_byte()).map_err(|e| { + RpcError::ValidationError(ValidationError::Custom(format!( + "Unable to parse transaction type: {e:?}" + ))) + }) + } +} + fn validate_difficulty(number: &Nat256, difficulty: Option) -> Result { const PARIS_BLOCK: u64 = 15_537_394; if number.as_ref() < &Nat::from(PARIS_BLOCK) { difficulty .map(U256::from) .ok_or(RpcError::ValidationError(ValidationError::Custom( - "Block before Paris upgrade but missing difficulty".into(), + "Missing difficulty field in pre Paris upgrade block".into(), ))) } else { match difficulty.map(U256::from) { None | Some(U256::ZERO) => Ok(U256::ZERO), _ => Err(RpcError::ValidationError(ValidationError::Custom( - "Block after Paris upgrade with non-zero difficulty".into(), + "Post Paris upgrade block has non-zero difficulty".into(), + ))), + } + } +} + +fn validate_receipt_status( + number: &Nat256, + root: Option, + status: Option, +) -> Result { + const BYZANTIUM_BLOCK: u64 = 4_370_000; + if number.as_ref() < &Nat::from(BYZANTIUM_BLOCK) { + match root { + None => Err(RpcError::ValidationError(ValidationError::Custom( + "Missing root field in transaction included before the Byzantium upgrade".into(), + ))), + Some(root) => Ok(alloy_consensus::Eip658Value::PostState(B256::from(root))), + } + } else { + match status.map(U256::from) { + None => Err(RpcError::ValidationError(ValidationError::Custom( + "Missing status field in transaction included after the Byzantium upgrade".into(), + ))), + Some(U256::ZERO) => Ok(alloy_consensus::Eip658Value::Eip658(false)), + Some(U256::ONE) => Ok(alloy_consensus::Eip658Value::Eip658(true)), + Some(_) => Err(RpcError::ValidationError(ValidationError::Custom( + "Post-Byzantium receipt has invalid status (expected 0 or 1)".into(), ))), } } diff --git a/evm_rpc_types/src/result/alloy.rs b/evm_rpc_types/src/result/alloy.rs index f8581c51..ea51334f 100644 --- a/evm_rpc_types/src/result/alloy.rs +++ b/evm_rpc_types/src/result/alloy.rs @@ -1,6 +1,6 @@ use crate::{ Block, FeeHistory, Hex, JsonRpcError, LogEntry, MultiRpcResult, Nat256, RpcError, - SendRawTransactionStatus, ValidationError, + SendRawTransactionStatus, TransactionReceipt, ValidationError, }; impl From>> for MultiRpcResult> { @@ -59,3 +59,15 @@ impl From> for MultiRpcResult>> + for MultiRpcResult> +{ + fn from(result: MultiRpcResult>) -> Self { + result.and_then(|maybe_receipt| { + maybe_receipt + .map(alloy_rpc_types::TransactionReceipt::try_from) + .transpose() + }) + } +} diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 68738244..337dcd95 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -142,6 +142,7 @@ impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { } } +#[derive(Clone)] pub struct JsonRpcResponse { pub status: u16, pub headers: Vec, diff --git a/tests/tests.rs b/tests/tests.rs index 8f4b7992..0a5de1ba 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -241,18 +241,6 @@ impl EvmRpcSetup { ) } - pub fn eth_get_transaction_receipt( - &self, - source: RpcServices, - config: Option, - tx_hash: &str, - ) -> CallFlow>> { - self.call_update( - "eth_getTransactionReceipt", - Encode!(&source, &config, &tx_hash).unwrap(), - ) - } - pub fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { self.call_update("updateApiKeys", Encode!(&api_keys).unwrap()) .wait() @@ -1003,92 +991,174 @@ async fn eth_get_block_by_number_should_be_consistent_when_total_difficulty_inco assert_eq!(response.header.total_difficulty, None); } -#[test] -fn eth_get_transaction_receipt_should_succeed() { +#[tokio::test] +async fn eth_get_transaction_receipt_should_succeed() { + fn request(tx_hash: impl ToString) -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getTransactionReceipt") + .with_params(json!([tx_hash.to_string()])) + } + let test_cases = [ - TestCase { - request: "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - raw_body: json!({"jsonrpc":"2.0","id":0,"result":{"blockHash":"0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be","blockNumber":"0x11a85ab","contractAddress":null,"cumulativeGasUsed":"0xf02aed","effectiveGasPrice":"0x63c00ee76","from":"0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667","gasUsed":"0x7d89","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","status":"0x1","to":"0x356cfd6e6d0000400000003900b415f80669009e","transactionHash":"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f","transactionIndex":"0xd9","type":"0x2"}}), - expected: evm_rpc_types::TransactionReceipt { - status: Some(0x1_u8.into()), - root: None, - transaction_hash: "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f".parse().unwrap(), + ( + b256!("0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f"), + JsonRpcResponse::from(json!({ + "jsonrpc":"2.0", + "id":0, + "result":{ + "blockHash":"0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be", + "blockNumber":"0x11a85ab", + "contractAddress":null, + "cumulativeGasUsed":"0xf02aed", + "effectiveGasPrice":"0x63c00ee76", + "from":"0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667", + "gasUsed":"0x7d89", + "logs":[], + "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status":"0x1", + "to":"0x356cfd6e6d0000400000003900b415f80669009e", + "transactionHash":"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", + "transactionIndex":"0xd9", + "type":"0x2" + } + })), + alloy_rpc_types::TransactionReceipt { + inner: alloy_consensus::ReceiptEnvelope::Eip1559(alloy_consensus::ReceiptWithBloom { + receipt: alloy_consensus::Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 0xf02aed_u64, + logs: vec![], + }, + logs_bloom: bloom!("0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + }), + transaction_hash: b256!("0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f"), + transaction_index: Some(0xd9_u64), + block_hash: Some(b256!("0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be")), + block_number: Some(0x11a85ab_u64), + gas_used: 0x7d89_u64, + effective_gas_price: 0x63c00ee76_u128, + blob_gas_used: None, + blob_gas_price: None, + from: address!("0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667"), + to: Some(address!("0x356cfd6e6d0000400000003900b415f80669009e")), contract_address: None, - block_number: 0x11a85ab_u64.into(), - block_hash: "0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be".parse().unwrap(), - effective_gas_price: 0x63c00ee76_u64.into(), - gas_used: 0x7d89_u32.into(), - from: "0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667".parse().unwrap(), - logs: vec![], - logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000".parse().unwrap(), - to: Some("0x356cfd6e6d0000400000003900b415f80669009e".parse().unwrap()), - transaction_index: 0xd9_u16.into(), - tx_type: "0x2".parse().unwrap(), - cumulative_gas_used: 0xf02aed_u64.into(), }, - }, - TestCase { //first transaction after genesis - request: "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060", - raw_body: json!({"jsonrpc":"2.0","id":0,"result":{"transactionHash":"0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060","blockHash":"0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd","blockNumber":"0xb443","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","gasUsed":"0x5208","root":"0x96a8e009d2b88b1483e6941e6812e32263b05683fac202abc622a3e31aed1957","contractAddress":null,"cumulativeGasUsed":"0x5208","transactionIndex":"0x0","from":"0xa1e4380a3b1f749673e270229993ee55f35663b4","to":"0x5df9b87991262f6ba471f09758cde1c0fc1de734","type":"0x0","effectiveGasPrice":"0x2d79883d2000","logs":[],"root":"0x96a8e009d2b88b1483e6941e6812e32263b05683fac202abc622a3e31aed1957"}}), - expected: evm_rpc_types::TransactionReceipt { - status: None, - root: Some("0x96a8e009d2b88b1483e6941e6812e32263b05683fac202abc622a3e31aed1957".parse().unwrap()), - transaction_hash: "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060".parse().unwrap(), + ), + // first transaction after genesis + ( + b256!("0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"), + JsonRpcResponse::from(json!({ + "jsonrpc":"2.0", + "id":0, + "result":{ + "transactionHash":"0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060", + "blockHash":"0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", + "blockNumber":"0xb443", + "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "gasUsed":"0x5208", + "root":"0x96a8e009d2b88b1483e6941e6812e32263b05683fac202abc622a3e31aed1957", + "contractAddress":null, + "cumulativeGasUsed":"0x5208", + "transactionIndex":"0x0", + "from":"0xa1e4380a3b1f749673e270229993ee55f35663b4", + "to":"0x5df9b87991262f6ba471f09758cde1c0fc1de734", + "type":"0x0", + "effectiveGasPrice":"0x2d79883d2000", + "logs":[], + } + })), + alloy_rpc_types::TransactionReceipt { + inner: alloy_consensus::ReceiptEnvelope::Legacy(alloy_consensus::ReceiptWithBloom { + receipt: alloy_consensus::Receipt { + status: alloy_consensus::Eip658Value::PostState(b256!("0x96a8e009d2b88b1483e6941e6812e32263b05683fac202abc622a3e31aed1957")), + cumulative_gas_used: 0x5208_u64, + logs: vec![], + }, + logs_bloom: bloom!("0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + }), + transaction_hash: b256!("0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"), + transaction_index: Some(0x0_u64), + block_hash: Some(b256!("0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd")), + block_number: Some(0xb443_u64), + gas_used: 0x5208_u64, + effective_gas_price: 0x2d79883d2000_u128, + blob_gas_used: None, + blob_gas_price: None, + from: address!("0xa1e4380a3b1f749673e270229993ee55f35663b4"), + to: Some(address!("0x5df9b87991262f6ba471f09758cde1c0fc1de734")), contract_address: None, - block_number: 0xb443_u64.into(), - block_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd".parse().unwrap(), - effective_gas_price: 0x2d79883d2000_u64.into(), - gas_used: 0x5208_u32.into(), - from: "0xa1e4380a3b1f749673e270229993ee55f35663b4".parse().unwrap(), - logs: vec![], - logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000".parse().unwrap(), - to: Some("0x5df9b87991262f6ba471f09758cde1c0fc1de734".parse().unwrap()), - transaction_index: 0x0_u16.into(), - tx_type: "0x0".parse().unwrap(), - cumulative_gas_used: 0x5208_u64.into(), }, - }, - TestCase { //contract creation - request: "0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b", - raw_body: json!({"jsonrpc":"2.0","id":0,"result":{"transactionHash":"0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b","blockHash":"0xd050426a753a7cc4833ba15a5dfcef761fd983f5277230ea8dc700eadd307363","blockNumber":"0x12e64fd","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","gasUsed":"0x69892","contractAddress":"0x6abda0438307733fc299e9c229fd3cc074bd8cc0","cumulativeGasUsed":"0x3009d2","transactionIndex":"0x17","from":"0xe12e9a6661aeaf57abf95fd060bebb223fbee7dd","to":null,"type":"0x2","effectiveGasPrice":"0x17c01a135","logs":[],"status":"0x1"}}), - expected: evm_rpc_types::TransactionReceipt { - status: Some(0x1_u8.into()), - root: None, - transaction_hash: "0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b".parse().unwrap(), - contract_address: Some("0x6abda0438307733fc299e9c229fd3cc074bd8cc0".parse().unwrap()), - block_number: 0x12e64fd_u64.into(), - block_hash: "0xd050426a753a7cc4833ba15a5dfcef761fd983f5277230ea8dc700eadd307363".parse().unwrap(), - effective_gas_price: 0x17c01a135_u64.into(), - gas_used: 0x69892_u32.into(), - from: "0xe12e9a6661aeaf57abf95fd060bebb223fbee7dd".parse().unwrap(), - logs: vec![], - logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000".parse().unwrap(), + ), + // contract creation + ( + b256!("0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b"), + JsonRpcResponse::from(json!({ + "jsonrpc":"2.0", + "id":0, + "result":{ + "transactionHash":"0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b", + "blockHash":"0xd050426a753a7cc4833ba15a5dfcef761fd983f5277230ea8dc700eadd307363", + "blockNumber":"0x12e64fd", + "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "gasUsed":"0x69892", + "contractAddress":"0x6abda0438307733fc299e9c229fd3cc074bd8cc0", + "cumulativeGasUsed":"0x3009d2", + "transactionIndex":"0x17", + "from":"0xe12e9a6661aeaf57abf95fd060bebb223fbee7dd", + "to":null, + "type":"0x2", + "effectiveGasPrice":"0x17c01a135", + "logs":[], + "status":"0x1" + } + })), + alloy_rpc_types::TransactionReceipt { + inner: alloy_consensus::ReceiptEnvelope::Eip1559(alloy_consensus::ReceiptWithBloom { + receipt: alloy_consensus::Receipt { + status: alloy_consensus::Eip658Value::Eip658(true), + cumulative_gas_used: 0x3009d2_u64, + logs: vec![], + }, + logs_bloom: bloom!("0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + }), + transaction_hash: b256!("0x2b8e12d42a187ace19c64b47fae0955def8859bf966c345102c6d3a52f28308b"), + transaction_index: Some(0x17_u64), + block_hash: Some(b256!("0xd050426a753a7cc4833ba15a5dfcef761fd983f5277230ea8dc700eadd307363")), + block_number: Some(0x12e64fd_u64), + gas_used: 0x69892_u64, + effective_gas_price: 0x17c01a135_u128, + blob_gas_used: None, + blob_gas_price: None, + from: address!("0xe12e9a6661aeaf57abf95fd060bebb223fbee7dd"), to: None, - transaction_index: 0x17_u16.into(), - tx_type: "0x2".parse().unwrap(), - cumulative_gas_used: 0x3009d2_u64.into(), + contract_address: Some(address!("0x6abda0438307733fc299e9c229fd3cc074bd8cc0")), }, - } + ) ]; - let mut offset = 0_u64; - let setup = EvmRpcSetup::new().mock_api_keys(); - for test_case in test_cases { + let mut offsets = (0_u64..).step_by(3); + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + for (tx_hash, response, expected) in test_cases { for source in RPC_SERVICES { - let mut responses: [serde_json::Value; 3] = - json_rpc_sequential_id(test_case.raw_body.clone()); - add_offset_json_rpc_id(responses.as_mut_slice(), offset); + let offset = offsets.next().unwrap(); + let mocks = MockHttpOutcallsBuilder::new() + .given(request(tx_hash).with_id(offset)) + .respond_with(response.clone().with_id(offset)) + .given(request(tx_hash).with_id(offset + 1)) + .respond_with(response.clone().with_id(offset + 1)) + .given(request(tx_hash).with_id(offset + 2)) + .respond_with(response.clone().with_id(offset + 2)); + let response = setup - .eth_get_transaction_receipt(source.clone(), None, test_case.request) - .mock_http_once(MockOutcallBuilder::new(200, responses[0].clone())) - .mock_http_once(MockOutcallBuilder::new(200, responses[1].clone())) - .mock_http_once(MockOutcallBuilder::new(200, responses[2].clone())) - .wait() + .client(mocks) + .with_rpc_sources(source.clone()) + .build() + .get_transaction_receipt(tx_hash) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, Some(test_case.expected.clone())); - offset += 3; + assert_eq!(response, Some(expected.clone())); } } } @@ -1265,47 +1335,82 @@ async fn eth_call_should_succeed() { } } -#[test] -fn candid_rpc_should_allow_unexpected_response_fields() { - let setup = EvmRpcSetup::new().mock_api_keys(); - let [response_0, response_1, response_2] = json_rpc_sequential_id( - json!({"jsonrpc":"2.0","id":0,"result":{"unexpectedKey":"unexpectedValue","blockHash":"0xb3b20624f8f0f86eb50dd04688409e5cea4bd02d700bf6e79e9384d47d6a5a35","blockNumber":"0x5bad55","contractAddress":null,"cumulativeGasUsed":"0xb90b0","effectiveGasPrice":"0x746a528800","from":"0x398137383b3d25c92898c656696e41950e47316b","gasUsed":"0x1383f","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","status":"0x1","to":"0x06012c8cf97bead5deae237070f9587f8e7a266d","transactionHash":"0xbb3a336e3f823ec18197f1e13ee875700f08f03e2cab75f0d0b118dabb44cba0","transactionIndex":"0x11","type":"0x0"}}), - ); +#[tokio::test] +async fn candid_rpc_should_allow_unexpected_response_fields() { + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ + "jsonrpc":"2.0", + "id": 0, + "result":{ + "unexpectedKey":"unexpectedValue", + "blockHash": "0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be", + "blockNumber": "0x11a85ab", + "contractAddress": null, + "cumulativeGasUsed": "0xf02aed", + "effectiveGasPrice": "0x63c00ee76", + "from": "0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667", + "gasUsed": "0x7d89", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x356cfd6e6d0000400000003900b415f80669009e", + "transactionHash": "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", + "transactionIndex": "0xd9", + "type": "0x2" + } + })) + } + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_receipt_request().with_id(0_u64)) + .respond_with(mock_response().with_id(0_u64)) + .given(get_transaction_receipt_request().with_id(1_u64)) + .respond_with(mock_response().with_id(1_u64)) + .given(get_transaction_receipt_request().with_id(2_u64)) + .respond_with(mock_response().with_id(2_u64)); + let response = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(None), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .mock_http_once(MockOutcallBuilder::new(200, response_0.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_1.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_2.clone())) - .wait() + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_consistent() .unwrap() .expect("receipt was None"); assert_eq!( response.block_hash, - "0xb3b20624f8f0f86eb50dd04688409e5cea4bd02d700bf6e79e9384d47d6a5a35" - .parse() - .unwrap() + Some(b256!( + "0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be" + )) ); } -#[test] -fn candid_rpc_should_err_without_cycles() { - let setup = EvmRpcSetup::with_args(InstallArgs { +#[tokio::test] +async fn candid_rpc_should_err_without_cycles() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { demo: None, ..Default::default() }) - .mock_api_keys(); + .await + .mock_api_keys() + .await; + let result = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(None), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .wait() + .client(MockHttpOutcalls::NEVER) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .with_cycles(0) + .send() + .await .expect_inconsistent(); // Because the expected cycles are different for each provider, the results are inconsistent // but should all be `TooFewCycles` error. @@ -1320,21 +1425,26 @@ fn candid_rpc_should_err_without_cycles() { } } -#[test] -fn candid_rpc_should_err_with_insufficient_cycles() { - let setup = EvmRpcSetup::with_args(InstallArgs { +#[tokio::test] +async fn candid_rpc_should_err_with_insufficient_cycles() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { demo: Some(true), nodes_in_subnet: Some(33), ..Default::default() }) - .mock_api_keys(); + .await + .mock_api_keys() + .await; + let mut result = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(None), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .wait() + .client(MockHttpOutcalls::NEVER) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_inconsistent(); let regex = regex::Regex::new( "http_request request sent with [0-9_]+ cycles, but [0-9_]+ cycles are required.", @@ -1352,54 +1462,67 @@ fn candid_rpc_should_err_with_insufficient_cycles() { ); // Same request should succeed after upgrade to the expected node count - setup.upgrade_canister(InstallArgs { - nodes_in_subnet: Some(34), - ..Default::default() - }); - let [response_0, response_1, response_2] = json_rpc_sequential_id( - json!({"jsonrpc":"2.0","id":0,"result":{"blockHash":"0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be","blockNumber":"0x11a85ab","contractAddress":null,"cumulativeGasUsed":"0xf02aed","effectiveGasPrice":"0x63c00ee76","from":"0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667","gasUsed":"0x7d89","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","status":"0x1","to":"0x356cfd6e6d0000400000003900b415f80669009e","transactionHash":"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f","transactionIndex":"0xd9","type":"0x2"}}), - ); + setup + .upgrade_canister(InstallArgs { + nodes_in_subnet: Some(34), + ..Default::default() + }) + .await; + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_receipt_request().with_id(0_u64)) + .respond_with(get_transaction_receipt_response().with_id(0_u64)) + .given(get_transaction_receipt_request().with_id(1_u64)) + .respond_with(get_transaction_receipt_response().with_id(1_u64)) + .given(get_transaction_receipt_request().with_id(2_u64)) + .respond_with(get_transaction_receipt_response().with_id(2_u64)); let result = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(None), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .mock_http_once(MockOutcallBuilder::new(200, response_0)) - .mock_http_once(MockOutcallBuilder::new(200, response_1)) - .mock_http_once(MockOutcallBuilder::new(200, response_2)) - .wait() + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_consistent() .unwrap(); - assert_matches!(result, Some(evm_rpc_types::TransactionReceipt { .. })); + assert_matches!(result, Some(alloy_rpc_types::TransactionReceipt { .. })); } -#[test] -fn candid_rpc_should_err_when_service_unavailable() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn candid_rpc_should_err_when_service_unavailable() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_receipt_request().with_id(0_u64)) + .respond_with(CanisterHttpReply::with_status(503).with_body("Service unavailable")) + .given(get_transaction_receipt_request().with_id(1_u64)) + .respond_with(CanisterHttpReply::with_status(503).with_body("Service unavailable")) + .given(get_transaction_receipt_request().with_id(2_u64)) + .respond_with(CanisterHttpReply::with_status(503).with_body("Service unavailable")); let result = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(None), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .mock_http(MockOutcallBuilder::new(503, "Service unavailable")) - .wait() + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_consistent(); assert_eq!( result, Err(RpcError::HttpOutcallError( HttpOutcallError::InvalidHttpJsonRpcResponse { status: 503, - body: "Service unavailable".to_string(), + body: "\"Service unavailable\"".to_string(), parsing_error: None } )) ); let rpc_method = || RpcMethod::EthGetTransactionReceipt.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), BLOCKPI_ETH_HOSTNAME.into()) => 1, @@ -1416,24 +1539,37 @@ fn candid_rpc_should_err_when_service_unavailable() { ); } -#[test] -fn candid_rpc_should_recognize_json_error() { - let setup = EvmRpcSetup::new().mock_api_keys(); - let [response_0, response_1] = json_rpc_sequential_id( - json!({"jsonrpc":"2.0","id":0,"error":{"code":123,"message":"Error message"}}), - ); +#[tokio::test] +async fn candid_rpc_should_recognize_json_error() { + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ + "jsonrpc":"2.0", + "id":0, + "error": { + "code":123, + "message":"Error message" + } + })) + } + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_receipt_request().with_id(0_u64)) + .respond_with(mock_response().with_id(0_u64)) + .given(get_transaction_receipt_request().with_id(1_u64)) + .respond_with(mock_response().with_id(1_u64)); let result = setup - .eth_get_transaction_receipt( - RpcServices::EthSepolia(Some(vec![ - EthSepoliaService::Ankr, - EthSepoliaService::BlockPi, - ])), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .mock_http_once(MockOutcallBuilder::new(200, response_0)) - .mock_http_once(MockOutcallBuilder::new(200, response_1)) - .wait() + .client(mocks) + .with_rpc_sources(RpcServices::EthSepolia(Some(vec![ + EthSepoliaService::Ankr, + EthSepoliaService::BlockPi, + ]))) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_consistent(); assert_eq!( result, @@ -1444,7 +1580,7 @@ fn candid_rpc_should_recognize_json_error() { ); let rpc_method = || RpcMethod::EthGetTransactionReceipt.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ANKR_HOSTNAME.into()) => 1, @@ -1459,16 +1595,18 @@ fn candid_rpc_should_recognize_json_error() { ); } -#[test] -fn candid_rpc_should_reject_empty_service_list() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn candid_rpc_should_reject_empty_service_list() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let result = setup - .eth_get_transaction_receipt( - RpcServices::EthMainnet(Some(vec![])), - None, - "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", - ) - .wait() + .client(MockHttpOutcalls::NEVER) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![]))) + .build() + .get_transaction_receipt(b256!( + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + )) + .send() + .await .expect_consistent(); assert_eq!( result, @@ -2747,6 +2885,23 @@ fn get_block_by_number_request() -> JsonRpcRequestMatcher { .with_id(0_u64) } +fn get_transaction_count_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getTransactionCount") + .with_params(json!([ + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "latest" + ])) + .with_id(0_u64) +} + +fn get_transaction_receipt_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getTransactionReceipt") + .with_params(json!([ + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" + ])) + .with_id(0_u64) +} + fn fee_history_request() -> JsonRpcRequestMatcher { JsonRpcRequestMatcher::with_method("eth_feeHistory") .with_params(json!(["0x3", "latest", []])) @@ -2759,19 +2914,33 @@ fn send_raw_transaction_request() -> JsonRpcRequestMatcher { .with_id(0_u64) } -fn get_transaction_count_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getTransactionCount") - .with_params(json!([ - "0xdac17f958d2ee523a2206206994597c13d831ec7", - "latest" - ])) - .with_id(0_u64) -} - fn get_transaction_count_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "jsonrpc" : "2.0", "id" : 0, "result" : "0x1" })) } +fn get_transaction_receipt_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { + "blockHash": "0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be", + "blockNumber": "0x11a85ab", + "contractAddress": null, + "cumulativeGasUsed": "0xf02aed", + "effectiveGasPrice": "0x63c00ee76", + "from": "0x0aa8ebb6ad5a8e499e550ae2c461197624c6e667", + "gasUsed": "0x7d89", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x356cfd6e6d0000400000003900b415f80669009e", + "transactionHash": "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f", + "transactionIndex": "0xd9", + "type": "0x2" + } + })) +} + pub fn multi_logs_for_single_transaction(num_logs: usize) -> serde_json::Value { let mut logs = Vec::with_capacity(num_logs); for log_index in 0..num_logs { @@ -2800,30 +2969,3 @@ fn single_log() -> ethers_core::types::Log { }); serde_json::from_value(json_value).expect("BUG: invalid log entry") } - -fn json_rpc_sequential_id(response: serde_json::Value) -> [serde_json::Value; N] { - let first_id = response["id"].as_u64().expect("missing request ID"); - let mut requests = Vec::with_capacity(N); - requests.push(response.clone()); - for i in 1..N { - let mut next_request = response.clone(); - let new_id = first_id + i as u64; - *next_request.get_mut("id").unwrap() = serde_json::Value::Number(new_id.into()); - requests.push(next_request); - } - requests.try_into().unwrap() -} - -fn add_offset_json_rpc_id(inputs: &mut [serde_json::Value], offset: u64) { - for input in inputs { - let current = input["id"].as_u64().expect("missing request ID"); - let new = current + offset; - input["id"] = serde_json::Value::Number(new.into()); - } -} - -pub struct TestCase { - pub request: Req, - pub raw_body: serde_json::Value, - pub expected: Res, -} From fb14c91d0c6564b945120eeba90e3080e550e968 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 23 Sep 2025 08:29:28 +0200 Subject: [PATCH 7/7] XC-412: Return transaction hash in `eth_sendRawTransaction` response --- tests/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tests.rs b/tests/tests.rs index 0a5de1ba..ada83fe7 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1244,7 +1244,7 @@ async fn eth_fee_history_should_succeed() { #[tokio::test] async fn eth_send_raw_transaction_should_succeed() { fn mock_response() -> JsonRpcResponse { - JsonRpcResponse::from(json!({ "id": 0, "jsonrpc": "2.0", "result": "Ok" })) + JsonRpcResponse::from(json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH })) } let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; @@ -1619,7 +1619,7 @@ 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": "Ok" }), + json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH }), )) .given(send_raw_transaction_request().with_id(1_u64)) .respond_with(JsonRpcResponse::from( @@ -2024,7 +2024,7 @@ 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": "Ok" }), + json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH }), )) .given(send_raw_transaction_request().with_id(1_u64)) .respond_with(JsonRpcResponse::from(