evm-rpc について
Internet Computer の canister から EVM RPC canister を介して Ethereum や EVM 互換チェーンを呼び出すためのスキルです。ネットワーク選択、合意処理、Rust クライアントの利用、デプロイ上の注意点を扱います。
主な機能
- 01Ethereum と EVM チェーンの JSON-RPC 呼び出し
- 02複数プロバイダーの結果照合
- 03既定チェーンとカスタムチェーンに対応
- 04型付き Rust クライアントの利用
- 05cycles と応答サイズの扱いを説明
活用シーン
- 01残高などのオンチェーンデータ取得
- 02IC canister から EVM 互換ネットワークを照会
- 03RPC プロバイダー間の不一致への対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill evm-rpcインストール後、Claude Code で/evm-rpcを入力して読み込まれたか確認します。
詳細
EVM RPC Canister — IC から Ethereum を呼び出す
概要
EVM RPC canister は、HTTPS アウトコールを介して Ethereum および EVM 互換チェーンに JSON-RPC 呼び出しをプロキシする IC システム canister です。お使いの canister が EVM RPC 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 Mainnet | 1 | variant { EthMainnet } | RpcServices::EthMainnet |
| Ethereum Sepolia | 11155111 | variant { EthSepolia } | RpcServices::EthSepolia |
| Arbitrum One | 42161 | variant { ArbitrumOne } | RpcServices::ArbitrumOne |
| Base Mainnet | 8453 | variant { BaseMainnet } | RpcServices::BaseMainnet |
| Optimism Mainnet | 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 が少なすぎると、失敗が通知されないまま処理が失敗したり、トラップが発生したりします。 -
デフォルトの
Equality合意方式を使用しています。 デフォルトの合意方式では、すべてのプロバイダーが同一の応答を返す必要があります。プロバイダー間で 1〜2 ブロックのずれがよくあるeth_getBlockByNumber(Latest)のようなクエリでは、この方式は失敗します。ほとんどの用途では、ConsensusStrategy::Threshold { total: Some(3), min: 2 }(3 件中 2 件の一致)を使用してください。 -
Inconsistent結果バリアントを無視しています。 閾値合意を使用しても、プロバイダー間の不一致が閾値を超える場合があります。複数プロバイダーへの呼び出しは、MultiRpcResult::Consistent(result)またはMultiRpcResult::Inconsistent(results)を返します。プロバイダー間で不一致があると canister がトラップするため、両方の分岐を必ず処理してください。 -
誤ったチェーンバリアントを使用しています。
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 型を手動で定義しています。 型付きクライアント API を提供し、
evm_rpc_typesのすべての Candid 型を再エクスポートするevm_rpc_clientcrate を使用してください。手動で定義した型は canister の実際のインターフェースとのずれが生じ、実行時にIC0503デコードトラップを引き起こします。
実装
icp.yaml の設定
evm_rpc キャニスター定義が必要なのはローカル開発時のみです。ローカルレプリカには EVM RPC キャニスターがあらかじめインストールされていないため、ビルド済みの WASM から独自のコピーをデプロイします。メインネットでは、DFINITY が管理するキャニスターがすでに 7hfb6-caaaa-aaaar-qadga-cai にデプロイされているため、独自のインスタンスをデプロイしないでください。環境を使って、デプロイ先ごとにデプロイするキャニスターを制御します。
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 クレートは、EVM RPC キャニスターを呼び出すための型付きクライアント API を提供します。サイクルの付与、引数のエンコード、レスポンスのデコードを処理します。すべての Candid 型は evm_rpc_types から再エクスポートされています。Alloy エコシステムを使用するプロジェクトでは、alloy Cargo 機能を有効にすると、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
)'