evm-rpc
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseEVM RPC Canister — Calling Ethereum from IC
EVM RPC Canister — 从IC调用以太坊
What This Is
功能介绍
The EVM RPC canister is an IC system canister that proxies JSON-RPC calls to Ethereum and EVM-compatible chains via HTTPS outcalls. Your canister sends a request to the EVM RPC canister, which fans it out to multiple RPC providers, compares responses for consensus, and returns the result. No API keys required for default providers. No bridges or oracles needed.
EVM RPC canister是一个IC系统canister,它通过HTTPS outcalls将JSON-RPC请求代理到以太坊及EVM兼容链。你的canister向EVM RPC canister发送请求,后者会将请求分发到多个RPC提供商,对比响应以达成共识,然后返回结果。默认提供商无需API密钥,也不需要桥接器或预言机。
Prerequisites
前置条件
- For Motoko: package manager,
mopsin mops.tomlcore = "2.0.0" - For Rust: ,
ic-cdk,candidserde
- 若使用Motoko:需包管理器,且在mops.toml中配置
mopscore = "2.0.0" - 若使用Rust:需、
ic-cdk、candid依赖serde
Canister IDs
Canister ID
| Canister | ID | Subnet |
|---|---|---|
| EVM RPC (mainnet) | | 34-node fiduciary |
| Canister | ID | 子网 |
|---|---|---|
| EVM RPC(主网) | | 34节点受托子网 |
Supported Chains
支持的链
| Chain | RpcServices Variant | Chain ID |
|---|---|---|
| Ethereum Mainnet | | 1 |
| Ethereum Sepolia | | 11155111 |
| Arbitrum One | | 42161 |
| Base Mainnet | | 8453 |
| Optimism Mainnet | | 10 |
| Custom EVM chain | | any |
| 链 | RpcServices 变体 | 链ID |
|---|---|---|
| 以太坊主网 | | 1 |
| 以太坊Sepolia测试网 | | 11155111 |
| Arbitrum One | | 42161 |
| Base主网 | | 8453 |
| Optimism主网 | | 10 |
| 自定义EVM链 | | 任意 |
RPC Providers
RPC提供商
Built-in providers (no API key needed for defaults):
| Provider | Ethereum | Sepolia | Arbitrum | Base | Optimism |
|---|---|---|---|---|---|
| Alchemy | yes | yes | yes | yes | yes |
| Ankr | yes | - | yes | yes | yes |
| BlockPi | yes | yes | yes | yes | yes |
| Cloudflare | yes | - | - | - | - |
| LlamaNodes | yes | - | yes | yes | yes |
| PublicNode | yes | yes | yes | yes | yes |
内置提供商(默认无需API密钥):
| 提供商 | 以太坊 | Sepolia | Arbitrum | Base | Optimism |
|---|---|---|---|---|---|
| Alchemy | 是 | 是 | 是 | 是 | 是 |
| Ankr | 是 | - | 是 | 是 | 是 |
| BlockPi | 是 | 是 | 是 | 是 | 是 |
| Cloudflare | 是 | - | - | - | - |
| LlamaNodes | 是 | - | 是 | 是 | 是 |
| PublicNode | 是 | 是 | 是 | 是 | 是 |
Cycle Costs
Cycle成本
Formula:
(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_countWhere = 34 (fiduciary subnet), = number of providers queried.
nodesrpc_countPractical guidance: Send 10_000_000_000 cycles (10B) as a starting budget. Unused cycles are refunded. Typical calls cost 100M-1B cycles (~$0.0001-$0.001 USD).
Use to get an exact estimate before calling.
requestCost计算公式:
(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count其中 = 34(受托子网), = 查询的提供商数量。
nodesrpc_count实用建议: 初始预算建议设置为10_000_000_000 Cycle(100亿)。未使用的Cycle会被退还。典型调用成本在1亿-10亿Cycle之间(约0.0001-0.001美元)。
可在调用前使用获取精确的成本估算。
requestCostMistakes That Break Your Build
导致构建失败的常见错误
-
Not sending enough cycles. Every EVM RPC call requires cycles attached. If you send too few, the call fails silently or traps. Start with 10B cycles and adjust down after verifying.
-
Ignoring theresult variant. Multi-provider calls return
Inconsistentor#Consistent(result). If providers disagree, you get#Inconsistent(results). Always handle both arms or your canister traps on provider disagreement.Inconsistent -
Using wrong chain variant.is for Ethereum L1. For Arbitrum use
#EthMainnet, for Base use#ArbitrumOne. Using the wrong variant queries the wrong chain.#BaseMainnet -
Forgettingfor optional config. The second argument to every RPC method is an optional config record. Pass
nullfor defaults. Omitting it causes a Candid type mismatch.null -
Response size limits. Large responses (e.g.,with broad filters) can exceed the max response size. Set
eth_getLogsappropriately or the call fails.max_response_bytes -
Callingwithout signing first. The EVM RPC canister does not sign transactions. You must sign the transaction yourself (using threshold ECDSA via the IC management canister) and pass the raw signed bytes.
eth_sendRawTransaction -
Usinginstead of
Cycles.addin mo:core. In mo:core 2.0,await (with cycles = ...)does not exist. Attach cycles usingCycles.add. This is the only way to attach cycles in mo:core.await (with cycles = AMOUNT) canister.method(args)
-
未附加足够的Cycle:每个EVM RPC调用都需要附加Cycle。如果附加的Cycle过少,调用会静默失败或触发陷阱。建议从100亿Cycle开始,验证后再逐步调整。
-
忽略结果变体:多提供商调用会返回
Inconsistent或#Consistent(result)。如果提供商返回的结果不一致,会得到#Inconsistent(results)。务必处理这两种情况,否则当提供商结果不一致时,你的canister会触发陷阱。Inconsistent -
使用错误的链变体:适用于以太坊L1。Arbitrum请使用
#EthMainnet,Base请使用#ArbitrumOne。使用错误的变体将查询错误的链。#BaseMainnet -
忘记为可选配置传递:每个RPC方法的第二个参数是可选的配置记录。使用默认值时请传递
null。省略该参数会导致Candid类型不匹配。null -
响应大小限制:大型响应(例如使用宽泛过滤器的)可能会超过最大响应大小。请适当设置
eth_getLogs,否则调用会失败。max_response_bytes -
调用前未签名:EVM RPC canister不负责交易签名。你必须自行完成交易签名(通过IC管理canister使用阈值ECDSA),然后传递原始签名字节。
eth_sendRawTransaction -
在mo:core中使用而非
Cycles.add:在mo:core 2.0中,await (with cycles = ...)已被移除。请使用Cycles.add来附加Cycle,这是mo:core中附加Cycle的唯一方式。await (with cycles = 金额) canister.method(参数)
Implementation
实现示例
icp.yaml Configuration
icp.yaml配置
The EVM RPC canister is deployed as a pre-built WASM alongside your backend. On mainnet, it is already deployed at — your backend calls it by principal directly.
7hfb6-caaaa-aaaar-qadga-caiyaml
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/backend/main.mo
- name: evm_rpc
build:
steps:
- type: pre-built
url: https://github.com/dfinity/evm-rpc-canister/releases/download/v2.2.0/evm_rpc.wasm.gz
init_args: "(record {})"EVM RPC canister会作为预构建的WASM与你的后端一同部署。在主网中,它已部署在——你的后端可以直接通过principal调用它。
7hfb6-caaaa-aaaar-qadga-caiyaml
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/backend/main.mo
- name: evm_rpc
build:
steps:
- type: pre-built
url: https://github.com/dfinity/evm-rpc-canister/releases/download/v2.2.0/evm_rpc.wasm.gz
init_args: "(record {})"Motoko
Motoko实现
mops.toml
mops.toml
toml
[package]
name = "evm-rpc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"toml
[package]
name = "evm-rpc-app"
version = "0.1.0"
[dependencies]
core = "2.0.0"src/backend/main.mo — Get ETH Balance
src/backend/main.mo — 获取ETH余额
motoko
import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor {
// Get ETH balance for an address on Ethereum mainnet
public func getEthBalance(address : Text) : async Text {
let services = #EthMainnet(null); // Use all default providers
let config = null;
// eth_call with balance check via raw JSON-RPC
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let maxResponseBytes : Nat64 = 1000;
// Get exact cost first
let cyclesResult = await EvmRpc.requestCost(#EthMainnet(#PublicNode), json, maxResponseBytes);
let cost = switch (cyclesResult) {
case (#Ok(c)) { c };
case (#Err(err)) { Runtime.trap("requestCost failed: " # debug_show err) };
};
let result = await (with cycles = cost) EvmRpc.request(
#EthMainnet(#PublicNode),
json,
maxResponseBytes
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("RPC error: " # debug_show err) };
}
};
// Get latest block using the typed API
public func getLatestBlock() : async ?EvmRpc.Block {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber(
services,
config,
#Latest
);
switch (result) {
case (#Consistent(#Ok(block))) { ?block };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_results)) {
Runtime.trap("Providers returned inconsistent results");
};
}
};
// Read ERC-20 token balance (e.g., USDC on Ethereum)
// Function selector for balanceOf(address): 0x70a08231
// Pad address to 32 bytes (remove 0x prefix, left-pad with zeros)
public func getErc20Balance(tokenContract : Text, walletAddress : Text) : async ?Text {
let services = #EthMainnet(null);
let config = null;
// Encode: balanceOf(address) = 0x70a08231 + address padded to 32 bytes
// walletAddress should be like "0xABC..." — strip 0x and left-pad to 64 hex chars
let calldata = "0x70a08231000000000000000000000000" # stripHexPrefix(walletAddress);
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_call(
services,
config,
{
block = null;
transaction = {
to = ?tokenContract;
input = ?calldata;
// All optional fields set to null
accessList = null;
blobVersionedHashes = null;
blobs = null;
chainId = null;
from = null;
gas = null;
gasPrice = null;
maxFeePerBlobGas = null;
maxFeePerGas = null;
maxPriorityFeePerGas = null;
nonce = null;
type_ = null;
value = null;
};
}
);
switch (result) {
case (#Consistent(#Ok(response))) { ?response };
case (#Consistent(#Err(error))) {
Runtime.trap("eth_call error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results from providers");
};
}
};
// Helper: strip "0x" prefix from hex string
func stripHexPrefix(hex : Text) : Text {
let chars = hex.chars();
switch (chars.next(), chars.next()) {
case (?"0", ?"x") {
var rest = "";
for (c in chars) { rest #= Text.fromChar(c) };
rest
};
case _ { hex };
}
};
// Send a signed raw transaction
public func sendRawTransaction(signedTxHex : Text) : async ?EvmRpc.SendRawTransactionStatus {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_sendRawTransaction(
services,
config,
signedTxHex
);
switch (result) {
case (#Consistent(#Ok(status))) { ?status };
case (#Consistent(#Err(error))) {
Runtime.trap("sendRawTransaction error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Get transaction receipt
public func getTransactionReceipt(txHash : Text) : async ?EvmRpc.TransactionReceipt {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getTransactionReceipt(
services,
config,
txHash
);
switch (result) {
case (#Consistent(#Ok(receipt))) { receipt };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Using a specific provider (instead of multi-provider consensus)
public func getBalanceViaPublicNode(address : Text) : async Text {
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let maxResponseBytes : Nat64 = 1000;
let result = await (with cycles = 10_000_000_000) EvmRpc.request(
#EthMainnet(#PublicNode), // Single specific provider
json,
maxResponseBytes
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("Error: " # debug_show err) };
}
};
// Querying a different chain (Arbitrum)
public func getArbitrumBlock() : async ?EvmRpc.Block {
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber(
#ArbitrumOne(null), // Arbitrum One
null,
#Latest
);
switch (result) {
case (#Consistent(#Ok(block))) { ?block };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Using a custom RPC endpoint
public func getBalanceCustomRpc(address : Text, rpcUrl : Text) : async Text {
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let result = await (with cycles = 10_000_000_000) EvmRpc.request(
#Custom({ url = rpcUrl; headers = null }),
json,
1000
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("Error: " # debug_show err) };
}
};
};motoko
import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor {
// Get ETH balance for an address on Ethereum mainnet
public func getEthBalance(address : Text) : async Text {
let services = #EthMainnet(null); // Use all default providers
let config = null;
// eth_call with balance check via raw JSON-RPC
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let maxResponseBytes : Nat64 = 1000;
// Get exact cost first
let cyclesResult = await EvmRpc.requestCost(#EthMainnet(#PublicNode), json, maxResponseBytes);
let cost = switch (cyclesResult) {
case (#Ok(c)) { c };
case (#Err(err)) { Runtime.trap("requestCost failed: " # debug_show err) };
};
let result = await (with cycles = cost) EvmRpc.request(
#EthMainnet(#PublicNode),
json,
maxResponseBytes
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("RPC error: " # debug_show err) };
}
};
// Get latest block using the typed API
public func getLatestBlock() : async ?EvmRpc.Block {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber(
services,
config,
#Latest
);
switch (result) {
case (#Consistent(#Ok(block))) { ?block };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_results)) {
Runtime.trap("Providers returned inconsistent results");
};
}
};
// Read ERC-20 token balance (e.g., USDC on Ethereum)
// Function selector for balanceOf(address): 0x70a08231
// Pad address to 32 bytes (remove 0x prefix, left-pad with zeros)
public func getErc20Balance(tokenContract : Text, walletAddress : Text) : async ?Text {
let services = #EthMainnet(null);
let config = null;
// Encode: balanceOf(address) = 0x70a08231 + address padded to 32 bytes
// walletAddress should be like "0xABC..." — strip 0x and left-pad to 64 hex chars
let calldata = "0x70a08231000000000000000000000000" # stripHexPrefix(walletAddress);
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_call(
services,
config,
{
block = null;
transaction = {
to = ?tokenContract;
input = ?calldata;
// All optional fields set to null
accessList = null;
blobVersionedHashes = null;
blobs = null;
chainId = null;
from = null;
gas = null;
gasPrice = null;
maxFeePerBlobGas = null;
maxFeePerGas = null;
maxPriorityFeePerGas = null;
nonce = null;
type_ = null;
value = null;
};
}
);
switch (result) {
case (#Consistent(#Ok(response))) { ?response };
case (#Consistent(#Err(error))) {
Runtime.trap("eth_call error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results from providers");
};
}
};
// Helper: strip "0x" prefix from hex string
func stripHexPrefix(hex : Text) : Text {
let chars = hex.chars();
switch (chars.next(), chars.next()) {
case (?"0", ?"x") {
var rest = "";
for (c in chars) { rest #= Text.fromChar(c) };
rest
};
case _ { hex };
}
};
// Send a signed raw transaction
public func sendRawTransaction(signedTxHex : Text) : async ?EvmRpc.SendRawTransactionStatus {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_sendRawTransaction(
services,
config,
signedTxHex
);
switch (result) {
case (#Consistent(#Ok(status))) { ?status };
case (#Consistent(#Err(error))) {
Runtime.trap("sendRawTransaction error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Get transaction receipt
public func getTransactionReceipt(txHash : Text) : async ?EvmRpc.TransactionReceipt {
let services = #EthMainnet(null);
let config = null;
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getTransactionReceipt(
services,
config,
txHash
);
switch (result) {
case (#Consistent(#Ok(receipt))) { receipt };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Using a specific provider (instead of multi-provider consensus)
public func getBalanceViaPublicNode(address : Text) : async Text {
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let maxResponseBytes : Nat64 = 1000;
let result = await (with cycles = 10_000_000_000) EvmRpc.request(
#EthMainnet(#PublicNode), // Single specific provider
json,
maxResponseBytes
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("Error: " # debug_show err) };
}
};
// Querying a different chain (Arbitrum)
public func getArbitrumBlock() : async ?EvmRpc.Block {
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber(
#ArbitrumOne(null), // Arbitrum One
null,
#Latest
);
switch (result) {
case (#Consistent(#Ok(block))) { ?block };
case (#Consistent(#Err(error))) {
Runtime.trap("Error: " # debug_show error);
};
case (#Inconsistent(_)) {
Runtime.trap("Inconsistent results");
};
}
};
// Using a custom RPC endpoint
public func getBalanceCustomRpc(address : Text, rpcUrl : Text) : async Text {
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}";
let result = await (with cycles = 10_000_000_000) EvmRpc.request(
#Custom({ url = rpcUrl; headers = null }),
json,
1000
);
switch (result) {
case (#Ok(response)) { response };
case (#Err(err)) { Runtime.trap("Error: " # debug_show err) };
}
};
};Rust
Rust实现
Cargo.toml
Cargo.toml
toml
[package]
name = "evm_rpc_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"toml
[package]
name = "evm_rpc_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"src/lib.rs
src/lib.rs
rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::call::Call;
use ic_cdk::update;
const EVM_RPC_CANISTER: &str = "7hfb6-caaaa-aaaar-qadga-cai";
fn evm_rpc_id() -> Principal {
Principal::from_text(EVM_RPC_CANISTER).unwrap()
}
// -- Types matching the EVM RPC canister Candid interface --
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcServices {
EthMainnet(Option<Vec<EthMainnetService>>),
EthSepolia(Option<Vec<EthSepoliaService>>),
ArbitrumOne(Option<Vec<L2MainnetService>>),
BaseMainnet(Option<Vec<L2MainnetService>>),
OptimismMainnet(Option<Vec<L2MainnetService>>),
Custom {
#[serde(rename = "chainId")]
chain_id: u64,
services: Vec<CustomRpcService>,
},
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcService {
EthMainnet(EthMainnetService),
EthSepolia(EthSepoliaService),
ArbitrumOne(L2MainnetService),
BaseMainnet(L2MainnetService),
OptimismMainnet(L2MainnetService),
Custom(CustomRpcService),
Provider(u64),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum EthMainnetService {
Alchemy,
Ankr,
BlockPi,
Cloudflare,
Llama,
PublicNode,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum EthSepoliaService {
Alchemy,
Ankr,
BlockPi,
PublicNode,
Sepolia,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum L2MainnetService {
Alchemy,
Ankr,
BlockPi,
Llama,
PublicNode,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct HttpHeader {
name: String,
value: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct CustomRpcService {
url: String,
headers: Option<Vec<HttpHeader>>,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum BlockTag {
Latest,
Safe,
Finalized,
Earliest,
Pending,
Number(candid::Nat),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum MultiResult<T> {
Consistent(RpcResult<T>),
Inconsistent(Vec<(RpcService, RpcResult<T>)>),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcResult<T> {
Ok(T),
Err(RpcError),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcError {
ProviderError(ProviderError),
HttpOutcallError(HttpOutcallError),
JsonRpcError(JsonRpcError),
ValidationError(ValidationError),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum ProviderError {
TooFewCycles { expected: candid::Nat, received: candid::Nat },
MissingRequiredProvider,
ProviderNotFound,
NoPermission,
InvalidRpcConfig(String),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RejectionCode {
NoError,
CanisterError,
SysTransient,
DestinationInvalid,
Unknown,
SysFatal,
CanisterReject,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum HttpOutcallError {
IcError { code: RejectionCode, message: String },
InvalidHttpJsonRpcResponse {
status: u16,
body: String,
#[serde(rename = "parsingError")]
parsing_error: Option<String>,
},
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct JsonRpcError {
code: i64,
message: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum ValidationError {
Custom(String),
InvalidHex(String),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct Block {
#[serde(rename = "baseFeePerGas")]
base_fee_per_gas: Option<candid::Nat>,
number: candid::Nat,
difficulty: Option<candid::Nat>,
#[serde(rename = "extraData")]
extra_data: String,
#[serde(rename = "gasLimit")]
gas_limit: candid::Nat,
#[serde(rename = "gasUsed")]
gas_used: candid::Nat,
hash: String,
#[serde(rename = "logsBloom")]
logs_bloom: String,
miner: String,
#[serde(rename = "mixHash")]
mix_hash: String,
nonce: candid::Nat,
#[serde(rename = "parentHash")]
parent_hash: String,
#[serde(rename = "receiptsRoot")]
receipts_root: String,
#[serde(rename = "sha3Uncles")]
sha3_uncles: String,
size: candid::Nat,
#[serde(rename = "stateRoot")]
state_root: String,
timestamp: candid::Nat,
#[serde(rename = "totalDifficulty")]
total_difficulty: Option<candid::Nat>,
transactions: Vec<String>,
#[serde(rename = "transactionsRoot")]
transactions_root: Option<String>,
uncles: Vec<String>,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum SendRawTransactionStatus {
Ok(Option<String>),
NonceTooLow,
NonceTooHigh,
InsufficientFunds,
}
// -- Get ETH balance via raw JSON-RPC --
#[update]
async fn get_eth_balance(address: String) -> String {
let json = format!(
r#"{{"jsonrpc":"2.0","method":"eth_getBalance","params":["{}","latest"],"id":1}}"#,
address
);
let max_response_bytes: u64 = 1000;
let cycles: u128 = 10_000_000_000;
let (result,): (Result<String, RpcError>,) = Call::unbounded_wait(evm_rpc_id(), "request")
.with_args(&(
RpcService::EthMainnet(EthMainnetService::PublicNode),
json,
max_response_bytes,
))
.with_cycles(cycles)
.await
.expect("Failed to call EVM RPC canister")
.candid_tuple()
.expect("Failed to decode response");
match result {
Ok(response) => response,
Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)),
}
}
// -- Get latest block via typed API --
#[update]
async fn get_latest_block() -> Block {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<Block>,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
.with_args(&(
RpcServices::EthMainnet(None),
None::<()>, // config
BlockTag::Latest,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_getBlockByNumber")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(block)) => block,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Providers returned inconsistent results")
}
}
}
// -- Read ERC-20 balance --
#[update]
async fn get_erc20_balance(token_contract: String, wallet_address: String) -> String {
// balanceOf(address) selector: 0x70a08231
// Pad the address to 32 bytes (strip 0x, left-pad with zeros)
let addr = wallet_address.trim_start_matches("0x");
let calldata = format!("0x70a08231{:0>64}", addr);
let json = format!(
r#"{{"jsonrpc":"2.0","method":"eth_call","params":[{{"to":"{}","data":"{}"}},"latest"],"id":1}}"#,
token_contract, calldata
);
let cycles: u128 = 10_000_000_000;
let (result,): (Result<String, RpcError>,) = Call::unbounded_wait(evm_rpc_id(), "request")
.with_args(&(
RpcService::EthMainnet(EthMainnetService::PublicNode),
json,
2048_u64,
))
.with_cycles(cycles)
.await
.expect("Failed to call EVM RPC canister")
.candid_tuple()
.expect("Failed to decode response");
match result {
Ok(response) => response,
Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)),
}
}
// -- Send signed raw transaction --
#[update]
async fn send_raw_transaction(signed_tx_hex: String) -> SendRawTransactionStatus {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<SendRawTransactionStatus>,) = Call::unbounded_wait(evm_rpc_id(), "eth_sendRawTransaction")
.with_args(&(
RpcServices::EthMainnet(None),
None::<()>,
signed_tx_hex,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_sendRawTransaction")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(status)) => status,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Providers returned inconsistent results")
}
}
}
// -- Query Arbitrum (different chain example) --
#[update]
async fn get_arbitrum_block() -> Block {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<Block>,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
.with_args(&(
RpcServices::ArbitrumOne(None),
None::<()>,
BlockTag::Latest,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_getBlockByNumber")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(block)) => block,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Inconsistent results")
}
}
}
ic_cdk::export_candid!();rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::call::Call;
use ic_cdk::update;
const EVM_RPC_CANISTER: &str = "7hfb6-caaaa-aaaar-qadga-cai";
fn evm_rpc_id() -> Principal {
Principal::from_text(EVM_RPC_CANISTER).unwrap()
}
// -- Types matching the EVM RPC canister Candid interface --
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcServices {
EthMainnet(Option<Vec<EthMainnetService>>),
EthSepolia(Option<Vec<EthSepoliaService>>),
ArbitrumOne(Option<Vec<L2MainnetService>>),
BaseMainnet(Option<Vec<L2MainnetService>>),
OptimismMainnet(Option<Vec<L2MainnetService>>),
Custom {
#[serde(rename = "chainId")]
chain_id: u64,
services: Vec<CustomRpcService>,
},
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcService {
EthMainnet(EthMainnetService),
EthSepolia(EthSepoliaService),
ArbitrumOne(L2MainnetService),
BaseMainnet(L2MainnetService),
OptimismMainnet(L2MainnetService),
Custom(CustomRpcService),
Provider(u64),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum EthMainnetService {
Alchemy,
Ankr,
BlockPi,
Cloudflare,
Llama,
PublicNode,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum EthSepoliaService {
Alchemy,
Ankr,
BlockPi,
PublicNode,
Sepolia,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum L2MainnetService {
Alchemy,
Ankr,
BlockPi,
Llama,
PublicNode,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct HttpHeader {
name: String,
value: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct CustomRpcService {
url: String,
headers: Option<Vec<HttpHeader>>,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum BlockTag {
Latest,
Safe,
Finalized,
Earliest,
Pending,
Number(candid::Nat),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum MultiResult<T> {
Consistent(RpcResult<T>),
Inconsistent(Vec<(RpcService, RpcResult<T>)>),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcResult<T> {
Ok(T),
Err(RpcError),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RpcError {
ProviderError(ProviderError),
HttpOutcallError(HttpOutcallError),
JsonRpcError(JsonRpcError),
ValidationError(ValidationError),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum ProviderError {
TooFewCycles { expected: candid::Nat, received: candid::Nat },
MissingRequiredProvider,
ProviderNotFound,
NoPermission,
InvalidRpcConfig(String),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum RejectionCode {
NoError,
CanisterError,
SysTransient,
DestinationInvalid,
Unknown,
SysFatal,
CanisterReject,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum HttpOutcallError {
IcError { code: RejectionCode, message: String },
InvalidHttpJsonRpcResponse {
status: u16,
body: String,
#[serde(rename = "parsingError")]
parsing_error: Option<String>,
},
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct JsonRpcError {
code: i64,
message: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum ValidationError {
Custom(String),
InvalidHex(String),
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct Block {
#[serde(rename = "baseFeePerGas")]
base_fee_per_gas: Option<candid::Nat>,
number: candid::Nat,
difficulty: Option<candid::Nat>,
#[serde(rename = "extraData")]
extra_data: String,
#[serde(rename = "gasLimit")]
gas_limit: candid::Nat,
#[serde(rename = "gasUsed")]
gas_used: candid::Nat,
hash: String,
#[serde(rename = "logsBloom")]
logs_bloom: String,
miner: String,
#[serde(rename = "mixHash")]
mix_hash: String,
nonce: candid::Nat,
#[serde(rename = "parentHash")]
parent_hash: String,
#[serde(rename = "receiptsRoot")]
receipts_root: String,
#[serde(rename = "sha3Uncles")]
sha3_uncles: String,
size: candid::Nat,
#[serde(rename = "stateRoot")]
state_root: String,
timestamp: candid::Nat,
#[serde(rename = "totalDifficulty")]
total_difficulty: Option<candid::Nat>,
transactions: Vec<String>,
#[serde(rename = "transactionsRoot")]
transactions_root: Option<String>,
uncles: Vec<String>,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
enum SendRawTransactionStatus {
Ok(Option<String>),
NonceTooLow,
NonceTooHigh,
InsufficientFunds,
}
// -- Get ETH balance via raw JSON-RPC --
#[update]
async fn get_eth_balance(address: String) -> String {
let json = format!(
r#"{{"jsonrpc":"2.0","method":"eth_getBalance","params":["{}","latest"],"id":1}}"#,
address
);
let max_response_bytes: u64 = 1000;
let cycles: u128 = 10_000_000_000;
let (result,): (Result<String, RpcError>,) = Call::unbounded_wait(evm_rpc_id(), "request")
.with_args(&(
RpcService::EthMainnet(EthMainnetService::PublicNode),
json,
max_response_bytes,
))
.with_cycles(cycles)
.await
.expect("Failed to call EVM RPC canister")
.candid_tuple()
.expect("Failed to decode response");
match result {
Ok(response) => response,
Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)),
}
}
// -- Get latest block via typed API --
#[update]
async fn get_latest_block() -> Block {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<Block>,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
.with_args(&(
RpcServices::EthMainnet(None),
None::<()>, // config
BlockTag::Latest,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_getBlockByNumber")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(block)) => block,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Providers returned inconsistent results")
}
}
}
// -- Read ERC-20 balance --
#[update]
async fn get_erc20_balance(token_contract: String, wallet_address: String) -> String {
// balanceOf(address) selector: 0x70a08231
// Pad the address to 32 bytes (strip 0x, left-pad with zeros)
let addr = wallet_address.trim_start_matches("0x");
let calldata = format!("0x70a08231{:0>64}", addr);
let json = format!(
r#"{{"jsonrpc":"2.0","method":"eth_call","params":[{{"to":"{}","data":"{}"}},"latest"],"id":1}}"#,
token_contract, calldata
);
let cycles: u128 = 10_000_000_000;
let (result,): (Result<String, RpcError>,) = Call::unbounded_wait(evm_rpc_id(), "request")
.with_args(&(
RpcService::EthMainnet(EthMainnetService::PublicNode),
json,
2048_u64,
))
.with_cycles(cycles)
.await
.expect("Failed to call EVM RPC canister")
.candid_tuple()
.expect("Failed to decode response");
match result {
Ok(response) => response,
Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)),
}
}
// -- Send signed raw transaction --
#[update]
async fn send_raw_transaction(signed_tx_hex: String) -> SendRawTransactionStatus {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<SendRawTransactionStatus>,) = Call::unbounded_wait(evm_rpc_id(), "eth_sendRawTransaction")
.with_args(&(
RpcServices::EthMainnet(None),
None::<()>,
signed_tx_hex,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_sendRawTransaction")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(status)) => status,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Providers returned inconsistent results")
}
}
}
// -- Query Arbitrum (different chain example) --
#[update]
async fn get_arbitrum_block() -> Block {
let cycles: u128 = 10_000_000_000;
let (result,): (MultiResult<Block>,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
.with_args(&(
RpcServices::ArbitrumOne(None),
None::<()>,
BlockTag::Latest,
))
.with_cycles(cycles)
.await
.expect("Failed to call eth_getBlockByNumber")
.candid_tuple()
.expect("Failed to decode response");
match result {
MultiResult::Consistent(RpcResult::Ok(block)) => block,
MultiResult::Consistent(RpcResult::Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiResult::Inconsistent(_) => {
ic_cdk::trap("Inconsistent results")
}
}
}
ic_cdk::export_candid!();Deploy & Test
部署与测试
Local Development
本地开发
bash
undefinedbash
undefinedStart local replica
启动本地副本节点
icp network start -d
icp network start -d
Pull the EVM RPC canister
拉取EVM RPC canister
icp deps pull
icp deps init evm_rpc --argument '(record {})'
icp deps deploy
icp deps pull
icp deps init evm_rpc --argument '(record {})'
icp deps deploy
Deploy your backend
部署你的后端
icp deploy backend
undefinedicp deploy backend
undefinedDeploy to Mainnet
部署到主网
bash
undefinedbash
undefinedOn mainnet, the EVM RPC canister is already deployed.
在主网中,EVM RPC canister已提前部署。
Your canister calls it directly by principal.
你的canister可以直接通过principal调用它。
icp deploy backend -e ic
undefinedicp deploy backend -e ic
undefinedTest via icp CLI
通过icp CLI测试
bash
undefinedbash
undefinedSet up variables
设置变量
export CYCLES=10000000000
export CYCLES=10000000000
Get ETH balance (raw JSON-RPC via single provider)
获取ETH余额(通过单个提供商的原始JSON-RPC调用)
icp canister call evm_rpc request '(
variant { EthMainnet = variant { PublicNode } },
"{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"],"id":1}",
1000
)' --with-cycles=$CYCLES
icp canister call evm_rpc request '(
variant { EthMainnet = variant { PublicNode } },
"{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"],"id":1}",
1000
)' --with-cycles=$CYCLES
Get latest block (typed API, multi-provider)
获取最新区块(通过类型化API,多提供商)
icp canister call evm_rpc eth_getBlockByNumber '(
variant { EthMainnet = null },
null,
variant { Latest }
)' --with-cycles=$CYCLES
icp canister call evm_rpc eth_getBlockByNumber '(
variant { EthMainnet = null },
null,
variant { Latest }
)' --with-cycles=$CYCLES
Get transaction receipt
获取交易收据
icp canister call evm_rpc eth_getTransactionReceipt '(
variant { EthMainnet = null },
null,
"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f"
)' --with-cycles=$CYCLES
icp canister call evm_rpc eth_getTransactionReceipt '(
variant { EthMainnet = null },
null,
"0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f"
)' --with-cycles=$CYCLES
Check available providers
查看可用的提供商
icp canister call evm_rpc getProviders
icp canister call evm_rpc getProviders
Estimate cost before calling
调用前估算成本
icp canister call evm_rpc requestCost '(
variant { EthMainnet = variant { PublicNode } },
"{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"],"id":1}",
1000
)'
undefinedicp canister call evm_rpc requestCost '(
variant { EthMainnet = variant { PublicNode } },
"{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"],"id":1}",
1000
)'
undefinedVerify It Works
验证功能正常
Check ETH Balance
检查ETH余额
bash
icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")'bash
icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")'Expected: JSON string like '{"jsonrpc":"2.0","id":1,"result":"0x..."}'
预期结果:JSON字符串,格式类似'{"jsonrpc":"2.0","id":1,"result":"0x..."}'
The result is the balance in wei (hex encoded)
result字段为以wei为单位的余额(十六进制编码)
undefinedundefinedCheck Latest Block
检查最新区块
bash
icp canister call backend get_latest_blockbash
icp canister call backend get_latest_blockExpected: record { number = ...; hash = "0x..."; timestamp = ...; ... }
预期结果:record结构,包含number、hash、timestamp等字段
undefinedundefinedCheck ERC-20 Balance (USDC)
检查ERC-20余额(以USDC为例)
bash
undefinedbash
undefinedUSDC contract on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
以太坊上的USDC合约地址:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
icp canister call backend get_erc20_balance '(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)'
icp canister call backend get_erc20_balance '(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)'
Expected: JSON with hex-encoded uint256 balance
预期结果:包含十六进制编码uint256余额的JSON
undefinedundefinedVerify Cycle Refunds
验证Cycle退款
Check your canister cycle balance before and after an RPC call:
bash
undefined在RPC调用前后检查你的canister的Cycle余额:
bash
undefinedBefore
调用前
icp canister status backend -e ic
icp canister status backend -e ic
Make a call
发起调用
icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")' -e ic
icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")' -e ic
After — unused cycles from the 10B budget are refunded
调用后——100亿Cycle预算中未使用的部分会被退还
icp canister status backend -e ic
undefinedicp canister status backend -e ic
undefined