Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8fd5e1f
Move validation to `main.rs`
lpahlavi Oct 24, 2025
24b69e2
Introduce `MultiRpcRequest`
lpahlavi Oct 24, 2025
bb612a6
Add `*_request_cost` methods to `CandidRpcClient`
lpahlavi Oct 24, 2025
a101ab5
Add `CyclesCost` endpoints
lpahlavi Oct 24, 2025
d7a8a66
Add integration tests
lpahlavi Oct 24, 2025
cbd94a1
Merge branch 'main' into lpahlavi/refactor-multi-rpc-requests
lpahlavi Oct 28, 2025
9542287
Merge branch 'lpahlavi/refactor-multi-rpc-requests' into lpahlavi/add…
lpahlavi Oct 28, 2025
93ed21a
Fix runtime
lpahlavi Oct 28, 2025
dea236d
Add `should_get_exact_cycles_cost`
lpahlavi Oct 29, 2025
ba4f8cc
Fix ID in legacy `request` endpoint test
lpahlavi Oct 29, 2025
77b4688
Revert unnecessary test change
lpahlavi Oct 29, 2025
a09a7a6
Fix `request` endpoint test
lpahlavi Oct 29, 2025
57455a1
Analogous change for response ID
lpahlavi Oct 29, 2025
aeb0131
Fix remaining tests
lpahlavi Oct 29, 2025
a6a0ed3
Merge branch 'main' into lpahlavi/refactor-multi-rpc-requests
lpahlavi Oct 30, 2025
a775dfc
Merge branch 'lpahlavi/refactor-multi-rpc-requests' into lpahlavi/add…
lpahlavi Oct 30, 2025
15e2636
Clean-up lifetimes
lpahlavi Oct 31, 2025
db0f883
Merge branch 'main' into lpahlavi/refactor-multi-rpc-requests
lpahlavi Oct 31, 2025
e4f81e1
Merge branch 'lpahlavi/refactor-multi-rpc-requests' into lpahlavi/add…
lpahlavi Oct 31, 2025
6fa2d3f
Fix merge
lpahlavi Oct 31, 2025
1d473a0
Merge branch 'main' into lpahlavi/add-cycles-cost-endpoints
lpahlavi Oct 31, 2025
8c8b82e
Remove redundant check for demo mode
lpahlavi Nov 3, 2025
f7bd7c1
Add shared method to validate `eth_get_logs` RPC config
lpahlavi Nov 3, 2025
7c78374
Clippy
lpahlavi Nov 3, 2025
95b7074
Clippy
lpahlavi Nov 3, 2025
03e6e9b
Clippy
lpahlavi Nov 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ num-traits = { workspace = true }
pocket-ic = { workspace = true }
proptest = { workspace = true }
rand = { workspace = true }
strum = { workspace = true }
tokio = { workspace = true }

[workspace.dependencies]
Expand Down
25 changes: 25 additions & 0 deletions candid/evm_rpc.did
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,44 @@ type MultiJsonRequestResult = variant {
Inconsistent : vec record { RpcService; JsonRequestResult };
};
service : (InstallArgs) -> {
// Call the `eth_feeHistory` RPC method and return the resulting fee history.
eth_feeHistory : (RpcServices, opt RpcConfig, FeeHistoryArgs) -> (MultiFeeHistoryResult);
eth_feeHistoryCyclesCost : (RpcServices, opt RpcConfig, FeeHistoryArgs) -> (RequestCostResult) query;

// Call the `eth_getBlockByNumber` RPC method and return the resulting block.
eth_getBlockByNumber : (RpcServices, opt RpcConfig, BlockTag) -> (MultiGetBlockByNumberResult);
eth_getBlockByNumberCyclesCost : (RpcServices, opt RpcConfig, BlockTag) -> (RequestCostResult) query;

// Call the `eth_getLogs` RPC method and return the resulting logs.
eth_getLogs : (RpcServices, opt GetLogsRpcConfig, GetLogsArgs) -> (MultiGetLogsResult);
eth_getLogsCyclesCost : (RpcServices, opt GetLogsRpcConfig, GetLogsArgs) -> (RequestCostResult) query;

// Call the `eth_getTransactionCount` RPC method and return the resulting transaction count.
eth_getTransactionCount : (RpcServices, opt RpcConfig, GetTransactionCountArgs) -> (MultiGetTransactionCountResult);
eth_getTransactionCountCyclesCost : (RpcServices, opt RpcConfig, GetTransactionCountArgs) -> (RequestCostResult) query;

// Call the `eth_getTransactionReceipt` RPC method and return the resulting transaction receipt.
eth_getTransactionReceipt : (RpcServices, opt RpcConfig, hash : text) -> (MultiGetTransactionReceiptResult);
eth_getTransactionReceiptCyclesCost : (RpcServices, opt RpcConfig, hash : text) -> (RequestCostResult) query;

// Call the `eth_sendRawTransaction` RPC method and return the resulting transaction hash.
eth_sendRawTransaction : (RpcServices, opt RpcConfig, rawSignedTransactionHex : text) -> (MultiSendRawTransactionResult);
eth_sendRawTransactionCyclesCost : (RpcServices, opt RpcConfig, rawSignedTransactionHex : text) -> (RequestCostResult) query;

// Call the `eth_call` RPC method and return the resulting output.
eth_call : (RpcServices, opt RpcConfig, CallArgs) -> (MultiCallResult);
eth_callCyclesCost : (RpcServices, opt RpcConfig, CallArgs) -> (RequestCostResult) query;

// Make a raw JSON-RPC request that sends the given JSON-RPC payload.
// This endpoint should be used instead of the `request` endpoint as it allows aggregating the responses from
// multiple providers. It also takes parameters consistent with `eth_*` endpoints, i.e. an `RpcServices` instead
// of an `RpcService`.
multi_request : (RpcServices, opt RpcConfig, json : text) -> (MultiJsonRequestResult);
multi_requestCyclesCost : (RpcServices, opt RpcConfig, json : text) -> (RequestCostResult) query;

// DEPRECATED: Use `multi_request` instead.
request : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestResult);
// DEPRECATED: Use the specific `*RequestCost` endpoints instead (e.g. `eth_feeHistoryRequestCost`).
requestCost : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestCostResult) query;

getNodesInSubnet : () -> (numberOfNodes : nat32) query;
Expand Down
58 changes: 47 additions & 11 deletions evm_rpc_client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,34 +33,45 @@
//! .build();
//! ```
//!
//! ## Specifying the amount of cycles to send
//! ## Estimating the amount of cycles to send
//!
//! Every call made to the EVM RPC canister that triggers HTTPs outcalls (e.g., `eth_getLogs`)
//! needs to attach some cycles to pay for the call.
//! By default, the client will attach some amount of cycles that should be sufficient for most cases.
//!
//! If this is not the case, the amount of cycles to be sent can be overridden. It's advisable to
//! actually send *more* cycles than required, since *unused cycles will be refunded*.
//! If this is not the case, the amount of cycles to be sent can be changed as follows:
//! 1. Determine the required amount of cycles to send for a particular request.
//! The EVM RPC canister offers some query endpoints (e.g., `eth_getLogsCyclesCost`) for that purpose.
//! This could help establishing a baseline so that the estimated cycles cost for similar requests
//! can be extrapolated from it instead of making additional queries to the EVM RPC canister.
//! 2. Override the amount of cycles to send for that particular request.
//! It's advisable to actually send *more* cycles than required, since *unused cycles will be refunded*.
//!
//! ```rust
//! use alloy_primitives::{address, U256};
//! use alloy_rpc_types::BlockNumberOrTag;
//! use evm_rpc_client::EvmRpcClient;
//!
//! # use evm_rpc_types::{MultiRpcResult, Nat256};
//! # use evm_rpc_types::{MultiRpcResult, Nat256, RpcError};
//! # #[tokio::main]
//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = EvmRpcClient::builder_for_ic()
//! .with_alloy()
//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(Nat256::from(1_u64))))
//! # .with_stub_responses()
//! # .with_response_for_method("eth_getTransactionCountCyclesCost", Ok::<u128, RpcError>(100_000_000_000))
//! # .with_response_for_method("eth_getTransactionCount", MultiRpcResult::Consistent(Ok(Nat256::from(1_u64))))
//! .build();
//!
//! let result = client
//! let request = client
//! .get_transaction_count((
//! address!("0xdac17f958d2ee523a2206206994597c13d831ec7"),
//! BlockNumberOrTag::Latest,
//! ))
//! .with_cycles(20_000_000_000)
//! ));
//!
//! let minimum_required_cycles_amount = request.clone().request_cost().send().await.unwrap();
//!
//! let result = request
//! .with_cycles(minimum_required_cycles_amount)
//! .send()
//! .await
//! .expect_consistent();
Expand Down Expand Up @@ -122,10 +133,11 @@ mod request;
mod retry;
mod runtime;

use crate::request::RequestCost;
use candid::{CandidType, Principal};
use evm_rpc_types::{
BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs,
Hex, Hex32, RpcConfig, RpcServices,
Hex, Hex32, RpcConfig, RpcResult, RpcServices,
};
#[cfg(feature = "alloy")]
pub use request::alloy::AlloyResponseConverter;
Expand All @@ -134,10 +146,10 @@ use request::{
FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder,
GetLogsRequest, GetLogsRequestBuilder, GetTransactionCountRequest,
GetTransactionCountRequestBuilder, GetTransactionReceiptRequest,
GetTransactionReceiptRequestBuilder, JsonRequest, JsonRequestBuilder, Request, RequestBuilder,
GetTransactionReceiptRequestBuilder, JsonRequest, JsonRequestBuilder,
SendRawTransactionRequest, SendRawTransactionRequestBuilder,
};
pub use request::{CandidResponseConverter, EvmRpcConfig};
pub use request::{CandidResponseConverter, EvmRpcConfig, EvmRpcEndpoint, Request, RequestBuilder};
pub use retry::{DoubleCycles, NoRetry, RetryPolicy};
pub use runtime::{IcError, IcRuntime, Runtime};
use serde::de::DeserializeOwned;
Expand Down Expand Up @@ -852,4 +864,28 @@ impl<Runtime: runtime::Runtime, Converter, RetryPolicy>
.await
.map(Into::into)
}

async fn execute_cycles_cost_request<Config, Params>(
&self,
request: RequestCost<Config, Params>,
) -> RpcResult<u128>
where
Config: CandidType + Send,
Params: CandidType + Send,
{
self.config
.runtime
.query_call::<(RpcServices, Option<Config>, Params), RpcResult<u128>>(
self.config.evm_rpc_canister,
request.endpoint.cycles_cost_method(),
(request.rpc_services, request.rpc_config, request.params),
)
.await
.unwrap_or_else(|e| {
panic!(
"Client error: failed to call `{}`: {e:?}",
request.endpoint.cycles_cost_method()
)
})
}
}
54 changes: 53 additions & 1 deletion evm_rpc_client/src/request/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use crate::{retry, runtime::IcError, EvmRpcClient, Runtime};
use candid::CandidType;
use evm_rpc_types::{
BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig,
GetTransactionCountArgs, Hex, Hex20, Hex32, MultiRpcResult, Nat256, RpcConfig, RpcServices,
GetTransactionCountArgs, Hex, Hex20, Hex32, MultiRpcResult, Nat256, RpcConfig, RpcResult,
RpcServices,
};
use serde::de::DeserializeOwned;
use std::fmt::{Debug, Formatter};
Expand Down Expand Up @@ -403,6 +404,20 @@ impl EvmRpcEndpoint {
Self::SendRawTransaction => "eth_sendRawTransaction",
}
}

/// Method name on the EVM RPC canister to estimate the amount of cycles for that request.
pub fn cycles_cost_method(&self) -> &'static str {
match &self {
Self::Call => "eth_callCyclesCost",
Self::FeeHistory => "eth_feeHistoryCyclesCost",
Self::GetBlockByNumber => "eth_getBlockByNumberCyclesCost",
Self::GetLogs => "eth_getLogsCyclesCost",
Self::GetTransactionCount => "eth_getTransactionCountCyclesCost",
Self::GetTransactionReceipt => "eth_getTransactionReceiptCyclesCost",
Self::MultiRequest => "multi_requestCyclesCost",
Self::SendRawTransaction => "eth_sendRawTransactionCyclesCost",
}
}
}

/// A builder to construct a [`Request`].
Expand Down Expand Up @@ -474,6 +489,24 @@ impl<Runtime, Converter, RetryPolicy, Config, Params, CandidOutput, Output>
}
}

/// Query the cycles cost for that request
pub fn request_cost(
self,
) -> RequestCostBuilder<Runtime, Converter, RetryPolicy, Config, Params> {
RequestCostBuilder {
client: self.client,
request: RequestCost {
endpoint: self.request.endpoint,
rpc_services: self.request.rpc_services,
rpc_config: self.request.rpc_config,
params: self.request.params,
cycles: 0,
_candid_marker: Default::default(),
_output_marker: Default::default(),
},
}
}

/// Change the amount of cycles to send for that request.
pub fn with_cycles(mut self, cycles: u128) -> Self {
*self.request.cycles_mut() = cycles;
Expand Down Expand Up @@ -719,6 +752,25 @@ impl<Config, Params, CandidOutput, Output> Request<Config, Params, CandidOutput,
}
}

pub type RequestCost<Config, Params> = Request<Config, Params, RpcResult<u128>, RpcResult<u128>>;

#[must_use = "RequestCostBuilder does nothing until you 'send' it"]
pub struct RequestCostBuilder<Runtime, Converter, RetryPolicy, Config, Params> {
client: EvmRpcClient<Runtime, Converter, RetryPolicy>,
request: RequestCost<Config, Params>,
}

impl<R: Runtime, C, P, Config, Params> RequestCostBuilder<R, C, P, Config, Params> {
/// Constructs the [`Request`] and send it using the [`EvmRpcClient`].
pub async fn send(self) -> RpcResult<u128>
where
Config: CandidType + Send,
Params: CandidType + Send,
{
self.client.execute_cycles_cost_request(self.request).await
}
}

// This trait is not public, otherwise adding a new endpoint to the EVM RPC canister would be
// a breaking change since it would add a new associated type to this trait.
pub trait EvmRpcResponseConverter {
Expand Down
15 changes: 14 additions & 1 deletion src/candid_rpc/cketh_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use crate::rpc_client::{
Hash, JsonByte, StorageKey,
},
};
use evm_rpc_types::{BlockTag, Hex, Hex20, Hex256, Hex32, HexByte, Nat256};
use canhttp::http::json::JsonRpcRequest;
use evm_rpc_types::{
BlockTag, Hex, Hex20, Hex256, Hex32, HexByte, Nat256, RpcError, RpcResult, ValidationError,
};
use ic_ethereum_types::Address;

pub(super) fn into_block_spec(value: BlockTag) -> BlockSpec {
Expand Down Expand Up @@ -269,3 +272,13 @@ pub(super) fn from_data(value: Data) -> Hex {
fn into_data(value: Hex) -> Data {
Data::from(Vec::<u8>::from(value))
}

pub(super) fn into_json_request(
json_rpc_payload: String,
) -> RpcResult<JsonRpcRequest<serde_json::Value>> {
serde_json::from_str(&json_rpc_payload).map_err(|e| {
RpcError::ValidationError(ValidationError::Custom(format!(
"Invalid JSON RPC request: {e}"
)))
})
}
Loading