diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd57856..48123ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* v2.2.0 feat: Support for `eth_call` method + ### Fixed -* v2.1.1 fix: ensure Candid API is the same as the interface exposed by the canister +* fix: ensure Candid API is the same as the interface exposed by the canister ## [2.1.0] - 2024-10-14 diff --git a/candid/evm_rpc.did b/candid/evm_rpc.did index 99c3fe29..4a496cc6 100644 --- a/candid/evm_rpc.did +++ b/candid/evm_rpc.did @@ -69,6 +69,31 @@ type GetLogsArgs = record { topics : opt vec Topic; }; type GetTransactionCountArgs = record { address : text; block : BlockTag }; +type CallArgs = record { + transaction : TransactionRequest; + block : opt BlockTag; +}; +type TransactionRequest = record { + "type" : opt text; + nonce : opt nat; + to : opt text; + from : opt text; + gas : opt nat; + value : opt nat; + input : opt text; + gasPrice : opt nat; + maxPriorityFeePerGas : opt nat; + maxFeePerGas : opt nat; + maxFeePerBlobGas : opt nat; + accessList: opt vec AccessListEntry; + blobVersionedHashes : opt vec text; + blobs : opt vec text; + chainId : opt nat; +}; +type AccessListEntry = record { + address : text; + storageKeys : vec text; +}; type HttpHeader = record { value : text; name : text }; type HttpOutcallError = variant { IcError : record { code : RejectionCode; message : text }; @@ -136,6 +161,10 @@ type MultiSendRawTransactionResult = variant { Consistent : SendRawTransactionResult; Inconsistent : vec record { RpcService; SendRawTransactionResult }; }; +type MultiCallResult = variant { + Consistent : CallResult; + Inconsistent : vec record { RpcService; CallResult }; +}; type ProviderError = variant { TooFewCycles : record { expected : nat; received : nat }; MissingRequiredProvider; @@ -185,6 +214,7 @@ type SendRawTransactionResult = variant { Ok : SendRawTransactionStatus; Err : RpcError; }; +type CallResult = variant { Ok : text; Err : RpcError }; type RequestResult = variant { Ok : text; Err : RpcError }; type RequestCostResult = variant { Ok : nat; Err : RpcError }; type RpcConfig = record { responseSizeEstimate : opt nat64; responseConsensus : opt ConsensusStrategy }; @@ -259,6 +289,7 @@ service : (InstallArgs) -> { eth_getTransactionCount : (RpcServices, opt RpcConfig, GetTransactionCountArgs) -> (MultiGetTransactionCountResult); eth_getTransactionReceipt : (RpcServices, opt RpcConfig, hash : text) -> (MultiGetTransactionReceiptResult); eth_sendRawTransaction : (RpcServices, opt RpcConfig, rawSignedTransactionHex : text) -> (MultiSendRawTransactionResult); + eth_call : (RpcServices, opt RpcConfig, CallArgs) -> (MultiCallResult); request : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestResult); requestCost : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestCostResult) query; getMetrics : () -> (Metrics) query; diff --git a/evm_rpc_types/CHANGELOG.md b/evm_rpc_types/CHANGELOG.md index 63fe76b6..aa0d67dd 100644 --- a/evm_rpc_types/CHANGELOG.md +++ b/evm_rpc_types/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- v1.2.0 Added types to support `eth_call.` + ## [1.1.0] - 2024-10-14 ### Changed diff --git a/evm_rpc_types/src/lib.rs b/evm_rpc_types/src/lib.rs index 80f2ce58..93324464 100644 --- a/evm_rpc_types/src/lib.rs +++ b/evm_rpc_types/src/lib.rs @@ -16,7 +16,10 @@ use std::fmt::{Debug, Display, Formatter}; use std::str::FromStr; pub use lifecycle::{InstallArgs, LogFilter, RegexString}; -pub use request::{BlockTag, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs}; +pub use request::{ + AccessList, AccessListEntry, BlockTag, CallArgs, FeeHistoryArgs, GetLogsArgs, + GetTransactionCountArgs, TransactionRequest, +}; pub use response::{Block, FeeHistory, LogEntry, SendRawTransactionStatus, TransactionReceipt}; pub use result::{ HttpOutcallError, JsonRpcError, MultiRpcResult, ProviderError, RpcError, RpcResult, @@ -242,3 +245,9 @@ impl From for HexByte { Self(Byte::from(value)) } } + +impl From for u8 { + fn from(value: HexByte) -> Self { + value.0.into_byte() + } +} diff --git a/evm_rpc_types/src/request/mod.rs b/evm_rpc_types/src/request/mod.rs index 3312215a..a9abf057 100644 --- a/evm_rpc_types/src/request/mod.rs +++ b/evm_rpc_types/src/request/mod.rs @@ -1,4 +1,4 @@ -use crate::{Hex20, Hex32, Nat256}; +use crate::{Hex, Hex20, Hex32, HexByte, Nat256}; use candid::CandidType; use serde::Deserialize; @@ -57,3 +57,81 @@ pub struct GetTransactionCountArgs { pub address: Hex20, pub block: BlockTag, } + +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub struct CallArgs { + pub transaction: TransactionRequest, + /// Integer block number, or "latest" for the last mined block or "pending", "earliest" for not yet mined transactions. + /// Default to "latest" if unspecified, see https://github.com/ethereum/execution-apis/issues/461. + pub block: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, CandidType, Deserialize)] +pub struct TransactionRequest { + /// The type of the transaction: + /// - "0x0" for legacy transactions (pre- EIP-2718) + /// - "0x1" for access list transactions (EIP-2930) + /// - "0x2" for EIP-1559 transactions + #[serde(rename = "type")] + pub tx_type: Option, + + /// Transaction nonce + pub nonce: Option, + + /// Address of the receiver or `None` in a contract creation transaction. + pub to: Option, + + /// The address of the sender. + pub from: Option, + + /// Gas limit for the transaction. + pub gas: Option, + + /// Amount of ETH sent with this transaction. + pub value: Option, + + /// Transaction input data + pub input: Option, + + /// The legacy gas price willing to be paid by the sender in wei. + #[serde(rename = "gasPrice")] + pub gas_price: Option, + + /// Maximum fee per gas the sender is willing to pay to miners in wei. + #[serde(rename = "maxPriorityFeePerGas")] + pub max_priority_fee_per_gas: Option, + + /// The maximum total fee per gas the sender is willing to pay (includes the network / base fee and miner / priority fee) in wei. + #[serde(rename = "maxFeePerGas")] + pub max_fee_per_gas: Option, + + /// The maximum total fee per gas the sender is willing to pay for blob gas in wei. + #[serde(rename = "maxFeePerBlobGas")] + pub max_fee_per_blob_gas: Option, + + /// EIP-2930 access list + #[serde(rename = "accessList")] + pub access_list: Option, + + /// List of versioned blob hashes associated with the transaction's EIP-4844 data blobs. + #[serde(rename = "blobVersionedHashes")] + pub blob_versioned_hashes: Option>, + + /// Raw blob data. + pub blobs: Option>, + + /// Chain ID that this transaction is valid on. + #[serde(rename = "chainId")] + pub chain_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +#[serde(transparent)] +pub struct AccessList(pub Vec); + +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +pub struct AccessListEntry { + pub address: Hex20, + #[serde(rename = "storageKeys")] + pub storage_keys: Vec, +} diff --git a/scripts/examples b/scripts/examples index 519ec0ba..ff5d1611 100755 --- a/scripts/examples +++ b/scripts/examples @@ -4,20 +4,22 @@ NETWORK=local IDENTITY=default CANISTER_ID=evm_rpc -CYCLES=1000000000 +CYCLES=10000000000 WALLET=$(dfx identity get-wallet --network=$NETWORK --identity=$IDENTITY) RPC_SERVICE="EthMainnet=variant {PublicNode}" RPC_SERVICES=EthMainnet -RPC_CONFIG=null +RPC_CONFIG="opt record {responseConsensus = opt variant {Threshold = record {total = opt (3 : nat8); min = 2 : nat8}}}" +# Use concrete block height to avoid flakiness on CI +BLOCK_HEIGHT="Number = 20000000" FLAGS="--network=$NETWORK --identity=$IDENTITY --with-cycles=$CYCLES --wallet=$WALLET" - dfx canister call $CANISTER_ID request "(variant {$RPC_SERVICE}, "'"{ \"jsonrpc\": \"2.0\", \"method\": \"eth_gasPrice\", \"params\": [], \"id\": 1 }"'", 1000)" $FLAGS || exit 1 -dfx canister call $CANISTER_ID eth_getLogs "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {addresses = vec {\"0xdAC17F958D2ee523a2206206994597C13D831ec7\"}})" $FLAGS || exit 1 -dfx canister call $CANISTER_ID eth_getBlockByNumber "(variant {$RPC_SERVICES}, $RPC_CONFIG, variant {Latest})" $FLAGS || exit 1 +dfx canister call $CANISTER_ID eth_getLogs "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {fromBlock = opt variant {$BLOCK_HEIGHT}; toBlock = opt variant {$BLOCK_HEIGHT}; addresses = vec {\"0xdAC17F958D2ee523a2206206994597C13D831ec7\"}})" $FLAGS || exit 1 +dfx canister call $CANISTER_ID eth_getBlockByNumber "(variant {$RPC_SERVICES}, $RPC_CONFIG, variant {$BLOCK_HEIGHT})" $FLAGS || exit 1 dfx canister call $CANISTER_ID eth_getTransactionReceipt "(variant {$RPC_SERVICES}, $RPC_CONFIG, \"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f\")" $FLAGS || exit 1 -dfx canister call $CANISTER_ID eth_getTransactionCount "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {address = \"0xdAC17F958D2ee523a2206206994597C13D831ec7\"; block = variant {Latest}})" $FLAGS || exit 1 -dfx canister call $CANISTER_ID eth_feeHistory "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {blockCount = 3; newestBlock = variant {Latest}})" $FLAGS || exit 1 +dfx canister call $CANISTER_ID eth_getTransactionCount "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {address = \"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\"; block = variant {$BLOCK_HEIGHT}})" $FLAGS || exit 1 +dfx canister call $CANISTER_ID eth_feeHistory "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {blockCount = 3; newestBlock = variant {$BLOCK_HEIGHT}})" $FLAGS || exit 1 dfx canister call $CANISTER_ID eth_sendRawTransaction "(variant {$RPC_SERVICES}, $RPC_CONFIG, \"0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83\")" $FLAGS || exit 1 +dfx canister call $CANISTER_ID eth_call "(variant {$RPC_SERVICES}, $RPC_CONFIG, record {transaction = record {to = opt \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\"; input = opt \"0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80\"}; block = opt variant {$BLOCK_HEIGHT}})" $FLAGS || exit 1 diff --git a/src/candid_rpc/cketh_conversion.rs b/src/candid_rpc/cketh_conversion.rs index cd0633dc..6fd06899 100644 --- a/src/candid_rpc/cketh_conversion.rs +++ b/src/candid_rpc/cketh_conversion.rs @@ -1,9 +1,15 @@ //! Conversion between JSON types and Candid EVM RPC types. -use crate::rpc_client::json::requests::BlockSpec; -use crate::rpc_client::json::Hash; -use evm_rpc_types::{BlockTag, Hex20}; -use evm_rpc_types::{Hex, Hex256, Hex32, HexByte, Nat256}; +use crate::rpc_client::{ + amount::Amount, + json::{ + requests::{AccessList, AccessListItem, BlockSpec, EthCallParams, TransactionRequest}, + responses::Data, + Hash, JsonByte, StorageKey, + }, +}; +use evm_rpc_types::{BlockTag, Hex, Hex20, Hex256, Hex32, HexByte, Nat256}; +use ic_ethereum_types::Address; pub(super) fn into_block_spec(value: BlockTag) -> BlockSpec { use crate::rpc_client::json::requests; @@ -186,6 +192,75 @@ pub(super) fn from_send_raw_transaction_result( } } +pub(super) fn into_eth_call_params(value: evm_rpc_types::CallArgs) -> EthCallParams { + EthCallParams { + transaction: into_transaction_request(value.transaction), + block: into_block_spec(value.block.unwrap_or_default()), + } +} + +fn into_transaction_request( + evm_rpc_types::TransactionRequest { + tx_type, + nonce, + to, + from, + gas, + value, + input, + gas_price, + max_priority_fee_per_gas, + max_fee_per_gas, + max_fee_per_blob_gas, + access_list, + blob_versioned_hashes, + blobs, + chain_id, + }: evm_rpc_types::TransactionRequest, +) -> TransactionRequest { + fn map_access_list(list: evm_rpc_types::AccessList) -> AccessList { + AccessList( + list.0 + .into_iter() + .map(|entry| AccessListItem { + address: Address::new(entry.address.into()), + storage_keys: entry + .storage_keys + .into_iter() + .map(|key| StorageKey::new(key.into())) + .collect(), + }) + .collect(), + ) + } + TransactionRequest { + tx_type: tx_type.map(|t| JsonByte::new(t.into())), + nonce: nonce.map(Amount::from), + to: to.map(|address| Address::new(address.into())), + from: from.map(|address| Address::new(address.into())), + gas: gas.map(Amount::from), + value: value.map(Amount::from), + input: input.map(into_data), + gas_price: gas_price.map(Amount::from), + max_priority_fee_per_gas: max_priority_fee_per_gas.map(Amount::from), + max_fee_per_gas: max_fee_per_gas.map(Amount::from), + max_fee_per_blob_gas: max_fee_per_blob_gas.map(Amount::from), + access_list: access_list.map(map_access_list), + blob_versioned_hashes: blob_versioned_hashes + .map(|hashes| hashes.into_iter().map(into_hash).collect()), + blobs: blobs.map(|blobs| blobs.into_iter().map(into_data).collect()), + chain_id: chain_id.map(Amount::from), + } +} + pub(super) fn into_hash(value: Hex32) -> Hash { Hash::new(value.into()) } + +pub(super) fn from_data(value: Data) -> Hex { + Hex::from(value.0) +} + +fn into_data(value: Hex) -> Data { + Data::from(Vec::::from(value)) +} diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index fa2721bc..18bb16d5 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -158,6 +158,18 @@ impl CandidRpcClient { ) .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) } + + pub async fn eth_call( + &self, + args: evm_rpc_types::CallArgs, + ) -> MultiRpcResult { + use crate::candid_rpc::cketh_conversion::{from_data, into_eth_call_params}; + process_result( + RpcMethod::EthFeeHistory, + self.client.eth_call(into_eth_call_params(args)).await, + ) + .map(from_data) + } } fn get_transaction_hash(raw_signed_transaction_hex: &Hex) -> Option { diff --git a/src/main.rs b/src/main.rs index f78064fb..0da4d43e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,6 +115,19 @@ pub async fn eth_send_raw_transaction( } } +#[update(name = "eth_call")] +#[candid_method(rename = "eth_call")] +pub async fn eth_call( + source: evm_rpc_types::RpcServices, + config: Option, + args: evm_rpc_types::CallArgs, +) -> MultiRpcResult { + match CandidRpcClient::new(source, config) { + Ok(source) => source.eth_call(args).await, + Err(err) => Err(err).into(), + } +} + #[update] #[candid_method] async fn request( diff --git a/src/rpc_client/json/mod.rs b/src/rpc_client/json/mod.rs index 8c80fb78..97e7e31e 100644 --- a/src/rpc_client/json/mod.rs +++ b/src/rpc_client/json/mod.rs @@ -7,6 +7,8 @@ use std::fmt::{Debug, Display, Formatter, LowerHex, UpperHex}; pub mod requests; pub mod responses; +#[cfg(test)] +mod tests; macro_rules! bytes_array { ($name: ident, $size: expr) => { @@ -74,6 +76,7 @@ macro_rules! bytes_array { bytes_array!(FixedSizeData, 32); bytes_array!(Hash, 32); bytes_array!(LogsBloom, 256); +bytes_array!(StorageKey, 32); #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] diff --git a/src/rpc_client/json/requests.rs b/src/rpc_client/json/requests.rs index 7df948d8..d1e660b1 100644 --- a/src/rpc_client/json/requests.rs +++ b/src/rpc_client/json/requests.rs @@ -1,8 +1,10 @@ -use crate::rpc_client::json::FixedSizeData; -use crate::rpc_client::numeric::{BlockNumber, NumBlocks}; -use candid::Deserialize; +use crate::rpc_client::json::responses::Data; +use crate::rpc_client::json::{FixedSizeData, Hash, JsonByte, StorageKey}; +use crate::rpc_client::numeric::{ + BlockNumber, ChainId, GasAmount, NumBlocks, TransactionNonce, Wei, WeiPerGas, +}; use ic_ethereum_types::Address; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::fmt; use std::fmt::{Display, Formatter}; @@ -143,6 +145,113 @@ impl From for (BlockSpec, bool) { } } +#[derive(Debug, Serialize, Clone)] +#[serde(into = "(TransactionRequest, BlockSpec)")] +pub struct EthCallParams { + pub transaction: TransactionRequest, + pub block: BlockSpec, +} + +impl From for (TransactionRequest, BlockSpec) { + fn from(value: EthCallParams) -> Self { + (value.transaction, value.block) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TransactionRequest { + /// The type of the transaction (e.g. "0x0" for legacy transactions, "0x2" for EIP-1559 transactions) + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub tx_type: Option, + + /// Transaction nonce + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + + /// Address of the receiver or `None` in a contract creation transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option
, + + /// The address of the sender. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option
, + + /// Gas limit for the transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub gas: Option, + + /// Amount of ETH sent with this transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// Transaction input data + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + + /// The legacy gas price willing to be paid by the sender in wei. + #[serde(rename = "gasPrice", skip_serializing_if = "Option::is_none")] + pub gas_price: Option, + + /// Maximum fee per gas the sender is willing to pay to miners in wei. + #[serde( + rename = "maxPriorityFeePerGas", + skip_serializing_if = "Option::is_none" + )] + pub max_priority_fee_per_gas: Option, + + /// The maximum total fee per gas the sender is willing to pay (includes the network / base fee and miner / priority fee) in wei. + #[serde(rename = "maxFeePerGas", skip_serializing_if = "Option::is_none")] + pub max_fee_per_gas: Option, + + /// The maximum total fee per gas the sender is willing to pay for blob gas in wei. + #[serde(rename = "maxFeePerBlobGas", skip_serializing_if = "Option::is_none")] + pub max_fee_per_blob_gas: Option, + + /// EIP-2930 access list + #[serde(rename = "accessList", skip_serializing_if = "Option::is_none")] + pub access_list: Option, + + /// List of versioned blob hashes associated with the transaction's EIP-4844 data blobs. + #[serde( + rename = "blobVersionedHashes", + skip_serializing_if = "Option::is_none" + )] + pub blob_versioned_hashes: Option>, + + /// Raw blob data. + #[serde(skip_serializing_if = "Option::is_none")] + pub blobs: Option>, + + /// Chain ID that this transaction is valid on. + #[serde(rename = "chainId", skip_serializing_if = "Option::is_none")] + pub chain_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(transparent)] +pub struct AccessList(pub Vec); + +impl AccessList { + pub fn new() -> Self { + Self(Vec::new()) + } +} + +impl Default for AccessList { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AccessListItem { + /// Accessed address + pub address: Address, + /// Accessed storage keys + #[serde(rename = "storageKeys")] + pub storage_keys: Vec, +} + /// An envelope for all JSON-RPC requests. #[derive(Clone, Serialize, Deserialize)] pub struct JsonRpcRequest { diff --git a/src/rpc_client/json/responses.rs b/src/rpc_client/json/responses.rs index 6453f31f..c75369f6 100644 --- a/src/rpc_client/json/responses.rs +++ b/src/rpc_client/json/responses.rs @@ -309,6 +309,14 @@ impl HttpResponsePayload for SendRawTransactionResult { #[serde(transparent)] pub struct Data(#[serde(with = "ic_ethereum_types::serde_data")] pub Vec); +impl HttpResponsePayload for Data {} + +impl From> for Data { + fn from(data: Vec) -> Self { + Self(data) + } +} + impl AsRef<[u8]> for Data { fn as_ref(&self) -> &[u8] { &self.0 diff --git a/src/rpc_client/json/tests.rs b/src/rpc_client/json/tests.rs new file mode 100644 index 00000000..aa079f94 --- /dev/null +++ b/src/rpc_client/json/tests.rs @@ -0,0 +1,100 @@ +use crate::rpc_client::json::requests::TransactionRequest; +use serde_json::json; + +#[test] +fn should_serialize_transaction_request_with_access_list() { + // output of + // curl --location 'https://eth-mainnet.alchemyapi.io/v2/demo' \ + // --header 'Content-Type: application/json' \ + // --data '{ + // "jsonrpc":"2.0", + // "method":"eth_getTransactionByHash", + // "params":[ + // "0xde78fe4a45109823845dc47c9030aac4c3efd3e5c540e229984d6f7b5eb4ec83" + // ], + // "id":1 + // }' + let minted_transaction = json!({ + "blockHash": "0xa81e656c368c6d8f4180c5f24560fb39f75af5bb970d809f04d499d1924f735e", + "blockNumber": "0xd5a0af", + "hash": "0xde78fe4a45109823845dc47c9030aac4c3efd3e5c540e229984d6f7b5eb4ec83", + "accessList": [ + { + "address": "0xa68dd8cb83097765263adad881af6eed479c4a33", + "storageKeys": [ + "0x0000000000000000000000000000000000000000000000000000000000000004", + "0x745448ebd86f892e3973b919a6686b32d8505f8eb2e02df5a36797f187adb881", + "0x0000000000000000000000000000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000000000000000000000000000011", + "0xa580422a537c1b63e41b8febf02c6c28bef8713a2a44af985cc8d4c2b24b1c86", + "0x91e3d6ffd1390da3bfbc0e0875515e89982841b064fcda9b67cffc63d8082ab6", + "0x91e3d6ffd1390da3bfbc0e0875515e89982841b064fcda9b67cffc63d8082ab8", + "0xbf9ee777cf4683df01da9dfd7aeab60490278463b1d516455d67d23c750f96dc", + "0x0000000000000000000000000000000000000000000000000000000000000012", + "0x000000000000000000000000000000000000000000000000000000000000000f", + "0x0000000000000000000000000000000000000000000000000000000000000010", + "0xa580422a537c1b63e41b8febf02c6c28bef8713a2a44af985cc8d4c2b24b1c88", + "0xbd9bbcf6ef1c613b05ca02fcfe3d4505eb1c5d375083cb127bda8b8afcd050fb", + "0x6306683371f43cb3203ee553ce8ac90eb82e4721cc5335d281e1e556d3edcdbc", + "0x0000000000000000000000000000000000000000000000000000000000000013", + "0xbd9bbcf6ef1c613b05ca02fcfe3d4505eb1c5d375083cb127bda8b8afcd050f9", + "0x0000000000000000000000000000000000000000000000000000000000000014" + ] + }, + { + "address": "0xab293dce330b92aa52bc2a7cd3816edaa75f890b", + "storageKeys": [ + "0x000000000000000000000000000000000000000000000000000000000000000c", + "0x0000000000000000000000000000000000000000000000000000000000000008", + "0x0000000000000000000000000000000000000000000000000000000000000006", + "0x0000000000000000000000000000000000000000000000000000000000000007" + ] + }, + { + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "storageKeys": [ + "0x51c9df7cdd01b5cb5fb293792b1e67ec1ac1048ae7e4c7cf6cf46883589dfbd4", + "0x3c679e5fc421e825187f885e3dcd7f4493f886ceeb4930450588e35818a32b9c" + ] + } + ], + "transactionIndex": "0x2", + "type": "0x02", //encoded on 2 hex characters to match encoding of JsonByte. Both 0x02 and 0x2 are valid. + "nonce": "0x12ec7", + "input": "0x0100d5a0afa68dd8cb83097765263adad881af6eed479c4a33ab293dce330b92aa52bc2a7cd3816edaa75f890b00000000000000000000000000000000000000000000007eb2e82c51126a5dde0a2e2a52f701", + "r": "0x20d7f34682e1c2834fcb0838e08be184ea6eba5189eda34c9a7561a209f7ed04", + "s": "0x7c63c158f32d26630a9732d7553cfc5b16cff01f0a72c41842da693821ccdfcb", + "chainId": "0x1", + "v": "0x0", + "gas": "0x3851d", + "maxPriorityFeePerGas": "0x23199fa3df8", + "from": "0x26ce7c1976c5eec83ea6ac22d83cb341b08850af", + "to": "0x00000000003b3cc22af3ae1eac0440bcee416b40", + "maxFeePerGas": "0x2e59652e99b", + "value": "0x0", + "gasPrice": "0x2d196bad689" + }); + + let actual_transaction_request = serde_json::to_value( + serde_json::from_value::(minted_transaction.clone()).unwrap(), + ) + .unwrap(); + + let expected_transaction_request = { + let mut request = minted_transaction.clone(); + for field in [ + "blockHash", + "blockNumber", + "hash", + "transactionIndex", + "r", + "s", + "v", + ] { + assert!(request.as_object_mut().unwrap().remove(field).is_some()); + } + request + }; + + assert_eq!(expected_transaction_request, actual_transaction_request); +} diff --git a/src/rpc_client/mod.rs b/src/rpc_client/mod.rs index 3228c362..dc568bdb 100644 --- a/src/rpc_client/mod.rs +++ b/src/rpc_client/mod.rs @@ -14,6 +14,8 @@ use json::Hash; use serde::{de::DeserializeOwned, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Debug; +use crate::rpc_client::json::requests::EthCallParams; +use crate::rpc_client::json::responses::Data; pub mod amount; pub(crate) mod eth_rpc; @@ -399,6 +401,16 @@ impl EthRpcClient { .await .reduce(self.consensus_strategy()) } + + pub async fn eth_call(&self, params: EthCallParams) -> Result> { + self.parallel_call( + "eth_call", + params, + self.response_size_estimate(256 + HEADER_SIZE_LIMIT), + ) + .await + .reduce(self.consensus_strategy()) + } } /// Aggregates responses of different providers to the same query. diff --git a/src/rpc_client/numeric/mod.rs b/src/rpc_client/numeric/mod.rs index 597e6659..89e212ee 100644 --- a/src/rpc_client/numeric/mod.rs +++ b/src/rpc_client/numeric/mod.rs @@ -17,6 +17,9 @@ pub enum TransactionCountTag {} /// but depending on the block height the two may differ. pub type TransactionCount = Amount; +pub enum TransactionNonceTag {} +pub type TransactionNonce = Amount; + pub enum TransactionIndexTag {} pub type TransactionIndex = Amount; @@ -44,3 +47,6 @@ pub type NumBytes = Amount; pub enum TimestampTag {} pub type Timestamp = Amount; + +pub enum ChainIdTag {} +pub type ChainId = Amount; \ No newline at end of file diff --git a/tests/mock.rs b/tests/mock.rs index ac1d83f0..65e71781 100644 --- a/tests/mock.rs +++ b/tests/mock.rs @@ -63,8 +63,12 @@ impl MockOutcallBuilder { self } - pub fn with_request_body(mut self, body: impl Into) -> Self { - self.0.request_body = Some(body.into().0); + pub fn with_raw_request_body(self, body: &str) -> Self { + self.with_request_body(MockJsonRequestBody::from_raw_request_unchecked(body)) + } + + pub fn with_request_body(mut self, body: impl Into) -> Self { + self.0.request_body = Some(body.into()); self } @@ -97,7 +101,7 @@ pub struct MockOutcall { pub method: Option, pub url: Option, pub request_headers: Option>, - pub request_body: Option>, + pub request_body: Option, pub max_response_bytes: Option, pub response: CanisterHttpReply, } @@ -116,11 +120,103 @@ impl MockOutcall { request.headers.iter().collect::>() ); } - if let Some(ref body) = self.request_body { - assert_eq!(body, &request.body); + if let Some(ref expected_body) = self.request_body { + let actual_body: serde_json::Value = serde_json::from_slice(&request.body) + .expect("BUG: failed to parse JSON request body"); + expected_body.assert_matches(&actual_body); } if let Some(max_response_bytes) = self.max_response_bytes { assert_eq!(Some(max_response_bytes), request.max_response_bytes); } } } + +/// Assertions on parts of the JSON-RPC request body. +#[derive(Clone, Debug)] +pub struct MockJsonRequestBody { + pub jsonrpc: String, + pub method: String, + pub id: Option, + pub params: Option, +} + +impl MockJsonRequestBody { + pub fn new(method: impl ToString) -> Self { + Self { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + id: None, + params: None, + } + } + + pub fn builder(method: impl ToString) -> MockJsonRequestBuilder { + MockJsonRequestBuilder(Self::new(method)) + } + + pub fn from_raw_request_unchecked(raw_request: &str) -> Self { + let request: serde_json::Value = + serde_json::from_str(raw_request).expect("BUG: failed to parse JSON request"); + Self { + jsonrpc: request["jsonrpc"] + .as_str() + .expect("BUG: missing jsonrpc field") + .to_string(), + method: request["method"] + .as_str() + .expect("BUG: missing method field") + .to_string(), + id: request["id"].as_u64(), + params: request.get("params").cloned(), + } + } + + pub fn assert_matches(&self, request_body: &serde_json::Value) { + assert_eq!( + self.jsonrpc, + request_body["jsonrpc"] + .as_str() + .expect("BUG: missing jsonrpc field") + ); + assert_eq!( + self.method, + request_body["method"] + .as_str() + .expect("BUG: missing method field") + ); + if let Some(id) = self.id { + assert_eq!( + id, + request_body["id"].as_u64().expect("BUG: missing id field") + ); + } + if let Some(expected_params) = &self.params { + assert_eq!( + expected_params, + request_body + .get("params") + .expect("BUG: missing params field") + ); + } + } +} + +#[derive(Clone, Debug)] +pub struct MockJsonRequestBuilder(MockJsonRequestBody); + +impl MockJsonRequestBuilder { + pub fn with_params(mut self, params: impl Into) -> Self { + self.0.params = Some(params.into()); + self + } + + pub fn build(self) -> MockJsonRequestBody { + self.0 + } +} + +impl From for MockJsonRequestBody { + fn from(builder: MockJsonRequestBuilder) -> Self { + builder.build() + } +} diff --git a/tests/tests.rs b/tests/tests.rs index e1a715f5..f8755985 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,5 +1,6 @@ mod mock; +use crate::mock::MockJsonRequestBody; use assert_matches::assert_matches; use candid::{CandidType, Decode, Encode, Nat, Principal}; use evm_rpc::logs::{Log, LogEntry}; @@ -24,6 +25,7 @@ use pocket_ic::common::rest::{ }; use pocket_ic::{CanisterSettings, PocketIc, WasmResult}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; use std::sync::Arc; use std::{marker::PhantomData, str::FromStr, time::Duration}; @@ -278,6 +280,15 @@ 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() @@ -463,7 +474,7 @@ fn mock_request_should_succeed_with_request_headers() { #[test] fn mock_request_should_succeed_with_request_body() { - mock_request(|builder| builder.with_request_body(MOCK_REQUEST_PAYLOAD)) + mock_request(|builder| builder.with_raw_request_body(MOCK_REQUEST_PAYLOAD)) } #[test] @@ -481,7 +492,7 @@ fn mock_request_should_succeed_with_all() { (CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE), ("Custom", "Value"), ]) - .with_request_body(MOCK_REQUEST_PAYLOAD) + .with_raw_request_body(MOCK_REQUEST_PAYLOAD) }) } @@ -506,7 +517,9 @@ fn mock_request_should_fail_with_request_headers() { #[test] #[should_panic(expected = "assertion `left == right` failed")] fn mock_request_should_fail_with_request_body() { - mock_request(|builder| builder.with_request_body(r#"{"different":"body"}"#)) + mock_request(|builder| { + builder.with_raw_request_body(r#"{"id":1,"jsonrpc":"2.0","method":"unknown_method"}"#) + }) } #[test] @@ -891,6 +904,61 @@ fn eth_send_raw_transaction_should_succeed() { } } +#[test] +fn eth_call_should_succeed() { + const ADDRESS: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const INPUT_DATA: &str = + "0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80"; + + let setup = EvmRpcSetup::new().mock_api_keys(); + for call_args in [ + evm_rpc_types::CallArgs { + transaction: evm_rpc_types::TransactionRequest { + to: Some(ADDRESS.parse().unwrap()), + input: Some(INPUT_DATA.parse().unwrap()), + ..evm_rpc_types::TransactionRequest::default() + }, + block: Some(evm_rpc_types::BlockTag::Latest), + }, + evm_rpc_types::CallArgs { + transaction: evm_rpc_types::TransactionRequest { + to: Some(ADDRESS.parse().unwrap()), + input: Some(INPUT_DATA.parse().unwrap()), + ..evm_rpc_types::TransactionRequest::default() + }, + block: None, //should be same as specifying Latest + }, + ] { + for source in RPC_SERVICES { + let response = setup + .eth_call(source.clone(), None, call_args.clone()) + .mock_http(MockOutcallBuilder::new( + 200, + r#"{"jsonrpc":"2.0","result":"0x0000000000000000000000000000000000000000000000000000013c3ee36e89","id":1}"#, + ) + .with_request_body( + MockJsonRequestBody::builder("eth_call") + .with_params(json!( + [ + { + "to": ADDRESS.to_lowercase(), + "input": INPUT_DATA.to_lowercase(), + }, + "latest" + ] + )))) + .wait() + .expect_consistent() + .unwrap(); + assert_eq!( + response, + Hex::from_str("0x0000000000000000000000000000000000000000000000000000013c3ee36e89") + .unwrap() + ); + } + } +} + #[test] fn candid_rpc_should_allow_unexpected_response_fields() { let setup = EvmRpcSetup::new().mock_api_keys();