关于 evm-rpc
用于指导 Internet Computer canister 通过 EVM RPC canister 调用 Ethereum 及兼容链的技能,涵盖网络选择、共识处理、Rust 客户端和部署要点。
主要功能
- 01调用 Ethereum 和 EVM 链的 JSON-RPC
- 02查询多个提供商并比较结果
- 03支持指定链和自定义链
- 04使用类型化 Rust 客户端
- 05说明 cycles 与响应大小处理
适用场景
- 01读取余额等链上数据
- 02从 IC canister 查询 EVM 兼容网络
- 03处理 RPC 提供商结果不一致
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill evm-rpc安装后在 Claude Code 中输入/evm-rpc确认已加载。
详细介绍
EVM RPC Canister — 从 IC 调用 Ethereum
这是什么
EVM RPC canister 是一个 IC 系统 canister,可通过 HTTPS 外部调用将 JSON-RPC 请求代理到 Ethereum 和兼容 EVM 的链。你的 canister 向 EVM RPC canister 发送请求,它会将请求分发给多个 RPC 提供商,比较响应以达成共识,然后返回结果。默认提供商无需 API 密钥。无需桥接或预言机。
前置条件
evm_rpc_clientcrate(提供类型化客户端 API,并重新导出evm_rpc_types)
Canister ID
| Canister | ID | 子网 |
|---|---|---|
| EVM RPC(主网) | 7hfb6-caaaa-aaaar-qadga-cai | 34 节点的受托子网 |
Candid 接口:https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.did — 也可以使用 canhelp 技能直接从主网 canister 获取。
支持的链
| 链 | Chain ID | Candid | Rust |
|---|---|---|---|
| Ethereum 主网 | 1 | variant { EthMainnet } | RpcServices::EthMainnet |
| Ethereum Sepolia | 11155111 | variant { EthSepolia } | RpcServices::EthSepolia |
| Arbitrum One | 42161 | variant { ArbitrumOne } | RpcServices::ArbitrumOne |
| Base 主网 | 8453 | variant { BaseMainnet } | RpcServices::BaseMainnet |
| Optimism 主网 | 10 | variant { OptimismMainnet } | RpcServices::OptimismMainnet |
| 自定义 EVM 链 | 任意 | variant { Custom } | RpcServices::Custom |
RPC 提供商
内置提供商(默认配置无需 API 密钥):
| 提供商 | Ethereum | Sepolia | Arbitrum | Base | Optimism |
|---|---|---|---|---|---|
| Alchemy | 是 | 是 | 是 | 是 | 是 |
| Ankr | 是 | - | 是 | 是 | 是 |
| BlockPi | 是 | 是 | 是 | 是 | 是 |
| Cloudflare | 是 | - | - | - | - |
| LlamaNodes | 是 | - | 是 | 是 | 是 |
| PublicNode | 是 | 是 | 是 | 是 | 是 |
Cycle 费用
公式:
(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count
其中,nodes = 34(受托子网),rpc_count = 查询的提供商数量。
实用建议: 先发送 10_000_000_000 cycles(10B)作为预算。未使用的 cycles 会退还。典型调用费用为 100M-1B cycles(约 $0.0001-$0.001 USD)。
调用前使用 requestCost 获取精确估算。
注意事项
-
发送的 cycles 不足。 每次 EVM RPC 调用都必须附带 cycles。
evm_rpc_client默认每次调用使用 10B cycles,但如果你通过.with_cycles()覆盖此设置,发送的 cycles 过少会导致静默失败或触发 trap。 -
使用默认的
Equality共识策略。 默认共识策略要求所有提供商返回完全相同的响应。对于eth_getBlockByNumber(Latest)这类查询,这通常会失败,因为各提供商的区块高度往往相差 1-2 个区块。大多数情况下应使用ConsensusStrategy::Threshold { total: Some(3), min: 2 }(3 个中有 2 个达成一致)。 -
忽略
Inconsistent结果变体。 即使使用阈值共识,提供商仍可能出现超出阈值的分歧。多提供商调用会返回MultiRpcResult::Consistent(result)或MultiRpcResult::Inconsistent(results)。务必处理这两种情况,否则提供商发生分歧时,你的 canister 会触发 trap。 -
使用错误的链变体。
RpcServices::EthMainnet用于 Ethereum L1。调用 Arbitrum 时使用RpcServices::ArbitrumOne,调用 Base 时使用RpcServices::BaseMainnet。使用错误的变体会查询错误的链。 -
响应大小限制。 较大的响应(例如,使用宽泛筛选条件调用
eth_getLogs)可能超过最大响应大小。使用客户端构建器上的.with_response_size_estimate(),否则调用会失败。 -
未先签名就调用
eth_sendRawTransaction。 EVM RPC canister 不会对交易进行签名。你必须自行签名交易(通过 IC 管理 canister 使用阈值 ECDSA),并传入已签名的原始字节。 -
手动定义 EVM RPC Candid 类型。 使用
evm_rpc_clientcrate,它提供类型化客户端 API,并重新导出evm_rpc_types中的所有 Candid 类型。手动定义的类型会与 canister 的实际接口不一致,导致运行时出现IC0503解码 trap。
实现
icp.yaml 配置
evm_rpc canister 定义仅用于本地开发——本地 replica 未预装 EVM RPC canister,因此你需要使用预构建的 WASM 部署自己的副本。在主网上,DFINITY 维护的 canister 已部署在 7hfb6-caaaa-aaaar-qadga-cai——请勿部署自己的实例。使用环境控制各个 canister 的部署位置:
canisters:
- name: backend
recipe:
type: "@dfinity/[email protected]"
- name: evm_rpc
build:
steps:
- type: pre-built
url: https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.wasm.gz
init_args: "(record {})"
environments:
- name: local
network: local
canisters: [backend, evm_rpc]
- name: ic
network: ic
canisters: [backend]
settings:
backend:
environment_variables:
PUBLIC_CANISTER_ID:evm_rpc: "7hfb6-caaaa-aaaar-qadga-cai"
Rust
evm_rpc_client crate 提供了用于调用 EVM RPC canister 的类型化客户端 API。它负责处理 cycles 附加、参数编码和响应解码。所有 Candid 类型都从 evm_rpc_types 重新导出。对于使用 Alloy 生态系统的项目,可启用 alloy Cargo feature,以使用 Alloy 原生请求和响应类型。
Cargo.toml
[package]
name = "evm_rpc_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
evm_rpc_client = "0.4"
evm_rpc_types = "3"
ic-canister-runtime = "0.2"
ic-cdk = "0.20"
candid = "0.10"
serde_json = "1"
src/lib.rs
use candid::Principal;
use evm_rpc_client::{CandidResponseConverter, EvmRpcClient, NoRetry};
use evm_rpc_types::{
Block, BlockTag, CallArgs, ConsensusStrategy,
Hex, Hex20, Hex32, MultiRpcResult, RpcServices, SendRawTransactionStatus,
};
use ic_canister_runtime::IcRuntime;
use ic_cdk::update;
use std::str::FromStr;
// Resolve the EVM RPC canister ID from environment variable injected by icp-cli.
// Locally: auto-injected with the locally deployed evm_rpc canister ID.
// Mainnet: set explicitly in icp.yaml to the well-known canister ID.
fn client() -> EvmRpcClient<IcRuntime, CandidResponseConverter, NoRetry> {
let canister_id = Principal::from_text(
ic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:evm_rpc"),
)
.expect("Invalid principal in PUBLIC_CANISTER_ID:evm_rpc");
EvmRpcClient::builder(IcRuntime::new(), canister_id)
.with_rpc_sources(RpcServices::EthMainnet(None))
.with_consensus_strategy(ConsensusStrategy::Threshold {
total: Some(3),
min: 2,
})
.build()
}
// -- Get ETH balance via raw JSON-RPC --
// Returns hex-encoded wei (e.g., "0xde0b6b3a7640000" = 1 ETH).
// Parse hex to u128, divide by 10^18 to get ETH.
#[update]
async fn get_eth_balance(address: String) -> String {
let json = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": [address, "latest"],
"id": 1
});
client()
.multi_request(json)
.send()
.await
.expect_consistent()
.unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err)))
}
// -- Get latest block via typed API --
#[update]
async fn get_latest_block() -> Block {
let result = client()
.get_block_by_number(BlockTag::Latest)
.send()
.await;
match result {
MultiRpcResult::Consistent(Ok(block)) => block,
MultiRpcResult::Consistent(Err(err)) => {
ic_cdk::trap(&format!("RPC error: {:?}", err))
}
MultiRpcResult::Inconsistent(_) => {
ic_cdk::trap("Providers returned inconsistent results")
}
}
}
// -- Read ERC-20 balance via eth_call --
// Returns hex-encoded uint256. Divide by 10^decimals (e.g., 6 for USDC, 18 for DAI).
// Function selector for balanceOf(address): 0x70a08231
// Pad address to 32 bytes (remove 0x prefix, left-pad with zeros)
#[update]
async fn get_erc20_balance(token_contract: String, wallet_address: String) -> String {
let addr = wallet_address.trim_start_matches("0x");
let calldata = format!("0x70a08231{:0>64}", addr);
let args = CallArgs {
transaction: evm_rpc_types::TransactionRequest {
to: Some(Hex20::from_str(&token_contract).unwrap()),
input: Some(Hex::from_str(&calldata).unwrap()),
..Default::default()
},
block: None,
};
let result = client()
.call(args)
.send()
.await
.expect_consistent()
.unwrap_or_else(|err| ic_cdk::trap(&format!("eth_call error: {:?}", err)));
result.to_string()
}
// -- Send a signed raw transaction --
#[update]
async fn send_raw_transaction(signed_tx_hex: String) -> SendRawTransactionStatus {
client()
.send_raw_transaction(Hex::from_str(&signed_tx_hex).unwrap())
.send()
.await
.expect_consistent()
.unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err)))
}
// -- Get transaction receipt --
#[update]
async fn get_transaction_receipt(tx_hash: String) -> evm_rpc_types::TransactionReceipt {
client()
.get_transaction_receipt(Hex32::from_str(&tx_hash).unwrap())
.send()
.await
.expect_consistent()
.unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err)))
.unwrap_or_else(|| ic_cdk::trap("Transaction receipt not found"))
}
// -- Override chain per request (e.g., Arbitrum instead of the client default) --
#[update]
async fn get_arbitrum_block() -> Block {
client()
.get_block_by_number(BlockTag::Latest)
.with_rpc_sources(RpcServices::ArbitrumOne(None))
.send()
.await
.expect_consistent()
.unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err)))
}
ic_cdk::export_candid!();
部署与测试
# Local: deploys both backend and evm_rpc (as defined in the local environment)
icp network start -d
icp deploy -e local
# Mainnet: deploys only backend (evm_rpc is excluded from the ic environment)
icp deploy -e ic
通过 icp CLI 测试
# Set up variables
export CYCLES=10000000000
# Get ETH balance (raw JSON-RPC via single provider)
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)
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
# Check available providers
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
)'