diff --git a/Cargo.lock b/Cargo.lock index b8df642e..f53e7676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,22 @@ dependencies = [ "serde", ] +[[package]] +name = "alloy-dyn-abi" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f56873f3cac7a2c63d8e98a4314b8311aa96adb1a0f82ae923eb2119809d2c" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + [[package]] name = "alloy-eip2124" version = "0.2.0" @@ -1576,6 +1592,7 @@ dependencies = [ name = "evm_rpc_client" version = "1.4.0" dependencies = [ + "alloy-dyn-abi", "alloy-primitives", "alloy-rpc-types", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 4cc5a4cd..5f3cbc48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ tokio = "1.44.1" [workspace.dependencies] alloy-consensus = "1.0.26" +alloy-dyn-abi = "1.3.1" alloy-primitives = "1.3.0" alloy-rpc-types = "1.0.23" assert_matches = "1.5.0" diff --git a/evm_rpc_client/Cargo.toml b/evm_rpc_client/Cargo.toml index cf13b9d4..8d403c19 100644 --- a/evm_rpc_client/Cargo.toml +++ b/evm_rpc_client/Cargo.toml @@ -22,4 +22,5 @@ serde = { workspace = true } strum = { workspace = true } [dev-dependencies] +alloy-dyn-abi = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index ebd574b6..ec823340 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -108,14 +108,14 @@ mod request; mod runtime; use crate::request::{ - FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, - GetBlockByNumberRequestBuilder, GetTransactionCountRequest, GetTransactionCountRequestBuilder, - Request, RequestBuilder, + CallRequest, CallRequestBuilder, FeeHistoryRequest, FeeHistoryRequestBuilder, + GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetTransactionCountRequest, + GetTransactionCountRequestBuilder, Request, RequestBuilder, }; use candid::{CandidType, Principal}; use evm_rpc_types::{ - BlockTag, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, RpcConfig, - RpcServices, + BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, + RpcConfig, RpcServices, }; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; @@ -250,6 +250,64 @@ impl ClientBuilder { } impl EvmRpcClient { + /// Call `eth_call` on the EVM RPC canister. + /// + /// # Examples + /// + /// This example sends an `eth_call` to the USDC ERC-20 contract to fetch its symbol, + /// then decodes the ABI-encoded response into the human-readable string `USDC`. + /// + /// ```rust + /// use alloy_dyn_abi::{DynSolType, DynSolValue}; + /// use alloy_primitives::{address, bytes}; + /// use alloy_rpc_types::BlockNumberOrTag; + /// use evm_rpc_client::EvmRpcClient; + /// + /// # use evm_rpc_types::{Hex, MultiRpcResult}; + /// # use std::str::FromStr; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = EvmRpcClient::builder_for_ic() + /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok( + /// # Hex::from_str("0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000045553444300000000000000000000000000000000000000000000000000000000").unwrap() + /// # ))) + /// .build(); + /// + /// let tx_request = alloy_rpc_types::TransactionRequest::default() + /// // USDC address + /// .from(address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")) + /// // Selector for `symbol()` + /// .input(bytes!(0x95, 0xd8, 0x9b, 0x41).into()); + /// + /// let result = client + /// .call(tx_request) + /// .with_block(BlockNumberOrTag::Latest) + /// .send() + /// .await + /// .expect_consistent() + /// .unwrap(); + /// + /// let decoded = DynSolType::String.abi_decode(&result); + /// assert_eq!(decoded, Ok(DynSolValue::from("USDC".to_string()))); + /// # Ok(()) + /// # } + /// ``` + pub fn call(&self, params: T) -> CallRequestBuilder + where + T: TryInto, + >::Error: std::fmt::Debug, + { + RequestBuilder::new( + self.clone(), + CallRequest::new( + params + .try_into() + .unwrap_or_else(|e| panic!("Invalid transaction request: {e:?}")), + ), + 10_000_000_000, + ) + } + /// Call `eth_getBlockByNumber` 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 4febf2ad..59a7ba9f 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -1,14 +1,54 @@ use crate::{EvmRpcClient, Runtime}; use candid::CandidType; use evm_rpc_types::{ - BlockTag, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig, GetTransactionCountArgs, Hex20, Hex32, - MultiRpcResult, Nat256, RpcConfig, RpcServices, + BlockTag, CallArgs, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig, GetTransactionCountArgs, + Hex, Hex20, Hex32, MultiRpcResult, Nat256, RpcConfig, RpcServices, }; use ic_error_types::RejectCode; use serde::de::DeserializeOwned; use std::fmt::{Debug, Formatter}; use strum::EnumIter; +#[derive(Debug, Clone)] +pub struct CallRequest(CallArgs); + +impl CallRequest { + pub fn new(params: CallArgs) -> Self { + Self(params) + } +} + +impl EvmRpcRequest for CallRequest { + type Config = RpcConfig; + type Params = CallArgs; + type CandidOutput = MultiRpcResult; + type Output = MultiRpcResult; + + fn endpoint(&self) -> EvmRpcEndpoint { + EvmRpcEndpoint::Call + } + + fn params(self) -> Self::Params { + self.0 + } +} + +pub type CallRequestBuilder = RequestBuilder< + R, + RpcConfig, + CallArgs, + MultiRpcResult, + MultiRpcResult, +>; + +impl CallRequestBuilder { + /// Change the `block` parameter for an `eth_call` request. + pub fn with_block(mut self, block: impl Into) -> Self { + self.request.params.block = Some(block.into()); + self + } +} + #[derive(Debug, Clone)] pub struct FeeHistoryRequest(FeeHistoryArgs); @@ -223,6 +263,8 @@ pub trait EvmRpcRequest { /// Endpoint on the EVM RPC canister triggering a call to EVM providers. #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, EnumIter)] pub enum EvmRpcEndpoint { + /// `eth_call` endpoint. + Call, /// `eth_feeHistory` endpoint. FeeHistory, /// `eth_getBlockByNumber` endpoint. @@ -237,6 +279,7 @@ impl EvmRpcEndpoint { /// Method name on the EVM RPC canister pub fn rpc_method(&self) -> &'static str { match &self { + Self::Call => "eth_call", Self::FeeHistory => "eth_feeHistory", Self::GetBlockByNumber => "eth_getBlockByNumber", Self::GetLogs => "eth_getLogs", diff --git a/evm_rpc_types/src/alloy.rs b/evm_rpc_types/src/alloy.rs index 4ecc823d..c11f636a 100644 --- a/evm_rpc_types/src/alloy.rs +++ b/evm_rpc_types/src/alloy.rs @@ -25,6 +25,12 @@ impl From for Hex32 { } } +impl From> for Hex { + fn from(value: alloy_primitives::FixedBytes) -> Self { + Self::from(value.to_vec()) + } +} + impl From for alloy_primitives::Bytes { fn from(value: Hex) -> Self { Self::from_iter(Vec::::from(value)) diff --git a/evm_rpc_types/src/request/alloy.rs b/evm_rpc_types/src/request/alloy.rs index c6a0bab4..0e04c02f 100644 --- a/evm_rpc_types/src/request/alloy.rs +++ b/evm_rpc_types/src/request/alloy.rs @@ -1,4 +1,8 @@ -use crate::{BlockTag, GetLogsArgs, Hex20, RpcError}; +use crate::{ + AccessList, AccessListEntry, BlockTag, CallArgs, GetLogsArgs, Hex, Hex20, Hex32, HexByte, + Nat256, RpcError, TransactionRequest, ValidationError, +}; +use alloy_primitives::TxKind; impl From for BlockTag { fn from(tag: alloy_rpc_types::BlockNumberOrTag) -> Self { @@ -40,4 +44,65 @@ impl, S: Into> From for GetLogsArgs { } } +impl TryFrom for CallArgs { + type Error = RpcError; + + fn try_from(request: alloy_rpc_types::TransactionRequest) -> Result { + Ok(Self { + transaction: TransactionRequest::try_from(request)?, + block: None, + }) + } +} + +impl TryFrom for TransactionRequest { + type Error = RpcError; + + fn try_from(tx_request: alloy_rpc_types::TransactionRequest) -> Result { + Ok(Self { + tx_type: tx_request.transaction_type.map(HexByte::from), + nonce: tx_request.nonce.map(Nat256::from), + to: tx_request.to.and_then(|kind| match kind { + TxKind::Create => None, + TxKind::Call(address) => Some(Hex20::from(address)), + }), + from: tx_request.from.map(Hex20::from), + gas: tx_request.gas.map(Nat256::from), + value: tx_request.value.map(Nat256::from), + input: tx_request + .input + .try_into_unique_input() + .map_err(|e| RpcError::ValidationError(ValidationError::Custom(e.to_string())))? + .map(Hex::from), + gas_price: tx_request.gas_price.map(Nat256::from), + max_priority_fee_per_gas: tx_request.max_priority_fee_per_gas.map(Nat256::from), + max_fee_per_gas: tx_request.max_fee_per_gas.map(Nat256::from), + max_fee_per_blob_gas: tx_request.max_fee_per_blob_gas.map(Nat256::from), + access_list: tx_request.access_list.map(AccessList::from), + blob_versioned_hashes: tx_request + .blob_versioned_hashes + .map(|hashes| hashes.into_iter().map(Hex32::from).collect()), + blobs: tx_request + .sidecar + .map(|sidecar| sidecar.blobs.into_iter().map(Hex::from).collect()), + chain_id: tx_request.chain_id.map(Nat256::from), + }) + } +} + +impl From for AccessList { + fn from(access_list: alloy_rpc_types::AccessList) -> Self { + Self( + access_list + .0 + .into_iter() + .map(|item| AccessListEntry { + address: Hex20::from(item.address), + storage_keys: item.storage_keys.into_iter().map(Hex32::from).collect(), + }) + .collect(), + ) + } +} + // TODO XC-412: impl From for GetLogsArgs diff --git a/evm_rpc_types/src/request/mod.rs b/evm_rpc_types/src/request/mod.rs index 7596be29..816a7f67 100644 --- a/evm_rpc_types/src/request/mod.rs +++ b/evm_rpc_types/src/request/mod.rs @@ -1,9 +1,14 @@ +#[cfg(test)] +mod tests; + #[cfg(feature = "alloy")] mod alloy; use crate::{Hex, Hex20, Hex32, HexByte, Nat256}; use candid::CandidType; use serde::Deserialize; +#[cfg(test)] +use serde::Serialize; #[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize, Default)] pub enum BlockTag { @@ -96,6 +101,7 @@ pub struct CallArgs { pub block: Option, } +#[cfg_attr(test, derive(Serialize))] #[derive(Clone, Debug, Default, PartialEq, Eq, CandidType, Deserialize)] pub struct TransactionRequest { /// The type of the transaction: @@ -155,10 +161,12 @@ pub struct TransactionRequest { pub chain_id: Option, } +#[cfg_attr(test, derive(Serialize))] #[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] #[serde(transparent)] pub struct AccessList(pub Vec); +#[cfg_attr(test, derive(Serialize))] #[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] pub struct AccessListEntry { pub address: Hex20, diff --git a/evm_rpc_types/src/request/tests.rs b/evm_rpc_types/src/request/tests.rs new file mode 100644 index 00000000..fd61eeb2 --- /dev/null +++ b/evm_rpc_types/src/request/tests.rs @@ -0,0 +1,154 @@ +#[cfg(feature = "alloy")] +mod alloy_conversion_tests { + use crate::TransactionRequest; + use alloy_primitives::{Address, Bytes, TxKind, B256, U256}; + use alloy_rpc_types::{AccessList, AccessListItem, TransactionInput}; + use proptest::{ + collection::vec, + option, + prelude::{any, Just, Strategy}, + prop_compose, prop_oneof, proptest, + }; + use serde_json::Value; + + proptest! { + #[test] + fn should_convert_tx_request_from_alloy(alloy_tx_request in arb_tx_request()) { + fn canonicalize (mut serialized: Value) -> Value { + // Remove null entries + if let Value::Object(ref mut v) = &mut serialized { + v.retain(|_, val| !val.is_null()); + } + + // Convert arrays of u32 digits to hex strings + fn convert_field_to_hex(v: &mut Value, field: &str) { + if let Some(Value::Array(arr)) = v.get_mut(field) { + let hex_str: String = arr + .into_iter() + .rev() + .map(|x| { + let n = x.as_u64().unwrap() as u32; + hex::encode(n.to_be_bytes()) + }) + .collect(); + + let hex_str = hex_str.trim_start_matches("0"); + let hex_str = if hex_str.is_empty() { "0" } else { hex_str }; + + *v.get_mut(field).unwrap() = Value::String(format!("0x{}", hex_str)); + } + } + + // Convert e.g. 0x00 to 0x0 or 0x01 to 0x1 + fn trim_leading_zeroes(v: &mut Value, field: &str) { + if let Value::Object(map) = v { + if let Some(Value::String(value)) = map.get_mut(field) { + if value.starts_with("0x0") { + *value = format!("0x{}", value.trim_start_matches("0x").trim_start_matches("0")); + } + } + } + } + + trim_leading_zeroes(&mut serialized, "type"); + + convert_field_to_hex(&mut serialized, "gasPrice"); + convert_field_to_hex(&mut serialized, "maxFeePerGas"); + convert_field_to_hex(&mut serialized, "maxPriorityFeePerGas"); + convert_field_to_hex(&mut serialized, "maxFeePerBlobGas"); + convert_field_to_hex(&mut serialized, "gas"); + convert_field_to_hex(&mut serialized, "value"); + convert_field_to_hex(&mut serialized, "nonce"); + convert_field_to_hex(&mut serialized, "chainId"); + + serialized + } + + + let tx_request = TransactionRequest::try_from(alloy_tx_request.clone()).unwrap(); + let serialized_tx_request = serde_json::to_value(&tx_request).unwrap(); + + let serialized_alloy_tx_request = serde_json::to_value(&alloy_tx_request.normalized_input()).unwrap(); + + assert_eq!(canonicalize(serialized_tx_request), canonicalize(serialized_alloy_tx_request)); + } + } + + prop_compose! { + fn arb_tx_request()( + from in option::of(arb_address()), + to in option::of(arb_tx_kind()), + gas_price in option::of(any::()), + max_fee_per_gas in option::of(any::()), + max_priority_fee_per_gas in option::of(any::()), + max_fee_per_blob_gas in option::of(any::()), + gas in option::of(any::()), + value in option::of(any::().prop_map(U256::from)), + input in arb_tx_input(), + nonce in option::of(any::()), + chain_id in option::of(any::()), + access_list in option::of(arb_access_list()), + transaction_type in option::of(any::()), + blob_versioned_hashes in option::of(arb_b256_vec()), + ) -> alloy_rpc_types::TransactionRequest { + alloy_rpc_types::TransactionRequest { + from, + to, + gas_price, + max_fee_per_gas, + max_priority_fee_per_gas, + max_fee_per_blob_gas, + gas, + value, + input, + nonce, + chain_id, + access_list, + transaction_type, + blob_versioned_hashes, + // Blobs are too big and cause a stack overflow error when running proptests. + sidecar: None, + // No corresponding field in `evm_rpc_types::TransactionRequest` + authorization_list: None, + } + } + } + + fn arb_address() -> impl Strategy { + any::<[u8; 20]>().prop_map(Address::from) + } + + fn arb_bytes() -> impl Strategy { + vec(any::(), 0..20).prop_map(|b| Bytes::from(b)) + } + + fn arb_tx_kind() -> impl Strategy { + prop_oneof![Just(TxKind::Create), arb_address().prop_map(TxKind::Call)] + } + + fn arb_tx_input() -> impl Strategy { + prop_oneof![ + Just(TransactionInput::default()), + arb_bytes().prop_map(|bytes| TransactionInput::new(bytes).normalized_input()), + arb_bytes().prop_map(|bytes| TransactionInput::new(bytes).normalized_data()), + arb_bytes().prop_map(|bytes| TransactionInput::new(bytes).with_both()), + ] + } + + fn arb_access_list() -> impl Strategy { + vec(arb_access_list_item(), 0..10).prop_map(AccessList::from) + } + + prop_compose! { + fn arb_access_list_item()( + address in arb_address(), + storage_keys in arb_b256_vec(), + ) -> AccessListItem { + AccessListItem { address, storage_keys } + } + } + + fn arb_b256_vec() -> impl Strategy> { + vec(any::<[u8; 32]>().prop_map(B256::from), 0..10) + } +} diff --git a/evm_rpc_types/src/result/alloy.rs b/evm_rpc_types/src/result/alloy.rs index f4e0aff9..01e78c84 100644 --- a/evm_rpc_types/src/result/alloy.rs +++ b/evm_rpc_types/src/result/alloy.rs @@ -1,4 +1,4 @@ -use crate::{Block, FeeHistory, LogEntry, MultiRpcResult, Nat256}; +use crate::{Block, FeeHistory, Hex, LogEntry, MultiRpcResult, Nat256}; impl From>> for MultiRpcResult> { fn from(result: MultiRpcResult>) -> Self { @@ -27,3 +27,9 @@ impl From> for MultiRpcResult { result.map(alloy_primitives::U256::from) } } + +impl From> for MultiRpcResult { + fn from(result: MultiRpcResult) -> Self { + result.map(alloy_primitives::Bytes::from) + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 966f1736..121fb941 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -4,7 +4,6 @@ mod setup; use crate::mock_http_runtime::mock::CanisterHttpReject; use crate::{ - mock::MockJsonRequestBody, mock_http_runtime::mock::{ json::{JsonRpcRequestMatcher, JsonRpcResponse}, CanisterHttpReply, MockHttpOutcalls, MockHttpOutcallsBuilder, @@ -268,15 +267,6 @@ impl EvmRpcSetup { ) } - pub fn eth_call( - &self, - source: RpcServices, - config: Option, - args: evm_rpc_types::CallArgs, - ) -> CallFlow> { - self.call_update("eth_call", Encode!(&source, &config, &args).unwrap()) - } - pub fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { self.call_update("updateApiKeys", Encode!(&api_keys).unwrap()) .wait() @@ -1233,19 +1223,25 @@ fn eth_send_raw_transaction_should_succeed() { } } -#[test] -fn eth_call_should_succeed() { +#[tokio::test] +async fn eth_call_should_succeed() { const ADDRESS: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const INPUT_DATA: &str = "0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80"; - let [response_0, response_1, response_2] = json_rpc_sequential_id( - json!({"jsonrpc":"2.0","result":"0x0000000000000000000000000000000000000000000000000000013c3ee36e89","id":0}), - ); - let expected_request = MockJsonRequestBody::builder("eth_call").with_params( - json!( [ { "to": ADDRESS.to_lowercase(), "input": INPUT_DATA.to_lowercase(), }, "latest" ]), - ); + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_call").with_params(json!( [ { "to": ADDRESS.to_lowercase(), "input": INPUT_DATA.to_lowercase(), }, "latest" ])) + } + + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from( + json!({ "jsonrpc": "2.0", "id": 0, "result": "0x0000000000000000000000000000000000000000000000000000013c3ee36e89" }), + ) + } + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let mut offsets = (0_u64..).step_by(3); for call_args in [ evm_rpc_types::CallArgs { transaction: evm_rpc_types::TransactionRequest { @@ -1253,7 +1249,7 @@ fn eth_call_should_succeed() { input: Some(INPUT_DATA.parse().unwrap()), ..evm_rpc_types::TransactionRequest::default() }, - block: Some(evm_rpc_types::BlockTag::Latest), + block: Some(BlockTag::Latest), }, evm_rpc_types::CallArgs { transaction: evm_rpc_types::TransactionRequest { @@ -1265,28 +1261,27 @@ fn eth_call_should_succeed() { }, ] { for source in RPC_SERVICES { - let setup = EvmRpcSetup::new().mock_api_keys(); + let offset = offsets.next().unwrap(); + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(offset)) + .respond_with(mock_response().with_id(offset)) + .given(mock_request().with_id(offset + 1)) + .respond_with(mock_response().with_id(offset + 1)) + .given(mock_request().with_id(offset + 2)) + .respond_with(mock_response().with_id(offset + 2)); + let response = setup - .eth_call(source.clone(), None, call_args.clone()) - .mock_http_once( - MockOutcallBuilder::new(200, response_0.clone()) - .with_request_body(expected_request.clone()), - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_1.clone()) - .with_request_body(expected_request.clone()), - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_2.clone()) - .with_request_body(expected_request.clone()), - ) - .wait() + .client(mocks) + .with_rpc_sources(source.clone()) + .build() + .call(call_args.clone()) + .send() + .await .expect_consistent() .unwrap(); assert_eq!( response, - Hex::from_str("0x0000000000000000000000000000000000000000000000000000013c3ee36e89") - .unwrap() + bytes!("0x0000000000000000000000000000000000000000000000000000013c3ee36e89") ); } }