evm-rpc

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

EVM 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:
    mops
    package manager,
    core = "2.0.0"
    in mops.toml
  • For Rust:
    ic-cdk
    ,
    candid
    ,
    serde
  • 若使用Motoko:需
    mops
    包管理器,且在mops.toml中配置
    core = "2.0.0"
  • 若使用Rust:需
    ic-cdk
    candid
    serde
    依赖

Canister IDs

Canister ID

CanisterIDSubnet
EVM RPC (mainnet)
7hfb6-caaaa-aaaar-qadga-cai
34-node fiduciary
CanisterID子网
EVM RPC(主网)
7hfb6-caaaa-aaaar-qadga-cai
34节点受托子网

Supported Chains

支持的链

ChainRpcServices VariantChain ID
Ethereum Mainnet
#EthMainnet
1
Ethereum Sepolia
#EthSepolia
11155111
Arbitrum One
#ArbitrumOne
42161
Base Mainnet
#BaseMainnet
8453
Optimism Mainnet
#OptimismMainnet
10
Custom EVM chain
#Custom
any
RpcServices 变体链ID
以太坊主网
#EthMainnet
1
以太坊Sepolia测试网
#EthSepolia
11155111
Arbitrum One
#ArbitrumOne
42161
Base主网
#BaseMainnet
8453
Optimism主网
#OptimismMainnet
10
自定义EVM链
#Custom
任意

RPC Providers

RPC提供商

Built-in providers (no API key needed for defaults):
ProviderEthereumSepoliaArbitrumBaseOptimism
Alchemyyesyesyesyesyes
Ankryes-yesyesyes
BlockPiyesyesyesyesyes
Cloudflareyes----
LlamaNodesyes-yesyesyes
PublicNodeyesyesyesyesyes
内置提供商(默认无需API密钥):
提供商以太坊SepoliaArbitrumBaseOptimism
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_count
Where
nodes
= 34 (fiduciary subnet),
rpc_count
= number of providers queried.
Practical 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
requestCost
to get an exact estimate before calling.
计算公式:
(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count
其中
nodes
= 34(受托子网),
rpc_count
= 查询的提供商数量。
实用建议: 初始预算建议设置为10_000_000_000 Cycle(100亿)。未使用的Cycle会被退还。典型调用成本在1亿-10亿Cycle之间(约0.0001-0.001美元)。
可在调用前使用
requestCost
获取精确的成本估算。

Mistakes That Break Your Build

导致构建失败的常见错误

  1. 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.
  2. Ignoring the
    Inconsistent
    result variant.
    Multi-provider calls return
    #Consistent(result)
    or
    #Inconsistent(results)
    . If providers disagree, you get
    Inconsistent
    . Always handle both arms or your canister traps on provider disagreement.
  3. Using wrong chain variant.
    #EthMainnet
    is for Ethereum L1. For Arbitrum use
    #ArbitrumOne
    , for Base use
    #BaseMainnet
    . Using the wrong variant queries the wrong chain.
  4. Forgetting
    null
    for optional config.
    The second argument to every RPC method is an optional config record. Pass
    null
    for defaults. Omitting it causes a Candid type mismatch.
  5. Response size limits. Large responses (e.g.,
    eth_getLogs
    with broad filters) can exceed the max response size. Set
    max_response_bytes
    appropriately or the call fails.
  6. Calling
    eth_sendRawTransaction
    without 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.
  7. Using
    Cycles.add
    instead of
    await (with cycles = ...)
    in mo:core.
    In mo:core 2.0,
    Cycles.add
    does not exist. Attach cycles using
    await (with cycles = AMOUNT) canister.method(args)
    . This is the only way to attach cycles in mo:core.
  1. 未附加足够的Cycle:每个EVM RPC调用都需要附加Cycle。如果附加的Cycle过少,调用会静默失败或触发陷阱。建议从100亿Cycle开始,验证后再逐步调整。
  2. 忽略
    Inconsistent
    结果变体
    :多提供商调用会返回
    #Consistent(result)
    #Inconsistent(results)
    。如果提供商返回的结果不一致,会得到
    Inconsistent
    。务必处理这两种情况,否则当提供商结果不一致时,你的canister会触发陷阱。
  3. 使用错误的链变体
    #EthMainnet
    适用于以太坊L1。Arbitrum请使用
    #ArbitrumOne
    ,Base请使用
    #BaseMainnet
    。使用错误的变体将查询错误的链。
  4. 忘记为可选配置传递
    null
    :每个RPC方法的第二个参数是可选的配置记录。使用默认值时请传递
    null
    。省略该参数会导致Candid类型不匹配。
  5. 响应大小限制:大型响应(例如使用宽泛过滤器的
    eth_getLogs
    )可能会超过最大响应大小。请适当设置
    max_response_bytes
    ,否则调用会失败。
  6. 调用
    eth_sendRawTransaction
    前未签名
    :EVM RPC canister不负责交易签名。你必须自行完成交易签名(通过IC管理canister使用阈值ECDSA),然后传递原始签名字节。
  7. 在mo:core中使用
    Cycles.add
    而非
    await (with cycles = ...)
    :在mo:core 2.0中,
    Cycles.add
    已被移除。请使用
    await (with cycles = 金额) canister.method(参数)
    来附加Cycle,这是mo:core中附加Cycle的唯一方式。

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
7hfb6-caaaa-aaaar-qadga-cai
— your backend calls it by principal directly.
yaml
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与你的后端一同部署。在主网中,它已部署在
7hfb6-caaaa-aaaar-qadga-cai
——你的后端可以直接通过principal调用它。
yaml
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
undefined
bash
undefined

Start 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
undefined
icp deploy backend
undefined

Deploy to Mainnet

部署到主网

bash
undefined
bash
undefined

On 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
undefined
icp deploy backend -e ic
undefined

Test via icp CLI

通过icp CLI测试

bash
undefined
bash
undefined

Set 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 )'
undefined
icp canister call evm_rpc requestCost '( variant { EthMainnet = variant { PublicNode } }, "{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"],"id":1}", 1000 )'
undefined

Verify 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为单位的余额(十六进制编码)

undefined
undefined

Check Latest Block

检查最新区块

bash
icp canister call backend get_latest_block
bash
icp canister call backend get_latest_block

Expected: record { number = ...; hash = "0x..."; timestamp = ...; ... }

预期结果:record结构,包含number、hash、timestamp等字段

undefined
undefined

Check ERC-20 Balance (USDC)

检查ERC-20余额(以USDC为例)

bash
undefined
bash
undefined

USDC 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

undefined
undefined

Verify Cycle Refunds

验证Cycle退款

Check your canister cycle balance before and after an RPC call:
bash
undefined
在RPC调用前后检查你的canister的Cycle余额:
bash
undefined

Before

调用前

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
undefined
icp canister status backend -e ic
undefined