For AI agents: Documentation index at /llms.txt

Skip to content

Ethereum Integration

ICP canisters can read data from Ethereum and other EVM-compatible chains, sign transactions with threshold ECDSA, and submit them onchain: all without bridges, oracles, or external signers. This guide covers the EVM RPC canister, which handles JSON-RPC calls to Ethereum nodes on your behalf.

For a conceptual overview of how ICP connects to other blockchains, see Chain Fusion.

How it works

The EVM RPC canister (7hfb6-caaaa-aaaar-qadga-cai) is a system canister deployed on ICP’s 34-node fiduciary subnet. When your canister calls it:

PlantUML diagram
  1. Your canister sends a request to the EVM RPC canister with cycles attached.
  2. The EVM RPC canister fans the request out to multiple RPC providers via HTTPS outcalls.
  3. Each provider’s response goes through ICP subnet consensus (at least 2/3 of nodes must agree).
  4. The EVM RPC canister compares the provider responses and returns either a Consistent result (providers agree) or an Inconsistent result (providers disagree).
  5. Unused cycles are refunded to your canister.

No API keys are required for the built-in providers. The EVM RPC canister manages authentication on your behalf.

Supported chains and providers

The EVM RPC canister supports Ethereum and several L2 networks out of the box. You can also connect to any EVM chain using a custom RPC endpoint.

ChainVariant (Motoko / Rust)Chain ID
Ethereum Mainnet#EthMainnet / EthMainnet1
Ethereum Sepolia#EthSepolia / EthSepolia11155111
Arbitrum One#ArbitrumOne / ArbitrumOne42161
Base Mainnet#BaseMainnet / BaseMainnet8453
Optimism Mainnet#OptimismMainnet / OptimismMainnet10
Custom#Custom / Customany

Built-in providers (no API key needed):

ProviderEthereumSepoliaArbitrumBaseOptimism
Alchemyyesyesyesyesyes
Ankryes-yesyesyes
BlockPiyesyesyesyesyes
Cloudflareyes----
LlamaNodesyes-yesyesyes
PublicNodeyesyesyesyesyes

Pass null (Motoko) or None (Rust) for the provider list to use all available defaults. To use a specific provider, pass it explicitly (e.g., #EthMainnet(#PublicNode) in Motoko, RpcService::EthMainnet(EthMainnetService::PublicNode) in Rust).

Reading data

The EVM RPC canister offers two styles of API:

  • Typed Candid-RPC methods like eth_getBlockByNumber and eth_getTransactionReceipt: these query multiple providers by default and return a MultiRpcResult with built-in consensus.
  • Raw JSON-RPC via the request method: sends a single JSON-RPC request to one provider. More flexible, but you handle parsing and consensus yourself.

Get the latest block (typed API)

import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
persistent actor {
public func getLatestBlock() : async ?EvmRpc.Block {
let result = await (with cycles = 10_000_000_000)
EvmRpc.eth_getBlockByNumber(
#EthMainnet(null), // all default providers
null, // default config
#Latest
);
switch (result) {
case (#Consistent(#Ok(block))) { ?block };
case (#Consistent(#Err(error))) {
Runtime.trap("RPC error: " # debug_show error);
};
case (#Inconsistent(_results)) {
Runtime.trap("Providers returned inconsistent results");
};
};
};
};

Always handle all three result variants: Consistent(Ok(...)), Consistent(Err(...)), and Inconsistent(...). Ignoring Inconsistent will cause your canister to trap when providers disagree.

Tip: For queries like eth_getBlockByNumber(Latest), use ConsensusStrategy::Threshold { total: Some(3), min: 2 } (2-of-3 agreement) instead of the default Equality strategy, since providers may be 1-2 blocks apart. Pass this as the consensus config parameter (third argument in Motoko, via the client builder in Rust with evm_rpc_client).

Get ETH balance (raw JSON-RPC)

The request method sends a raw JSON-RPC payload to a single provider. This is useful for methods not covered by the typed API, or when you want direct control over the request.

import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
persistent actor {
public func getEthBalance(address : Text) : async Text {
let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\""
# address # "\",\"latest\"],\"id\":1}";
let maxResponseBytes : Nat64 = 1000;
// Get exact cost first
let costResult = await EvmRpc.requestCost(
#EthMainnet(#PublicNode), json, maxResponseBytes
);
let cost = switch (costResult) {
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);
};
};
};
};

Read an ERC-20 token balance

To read an ERC-20 balance, use eth_call with the balanceOf(address) function selector (0x70a08231).

import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
persistent actor {
public func getErc20Balance(
tokenContract : Text,
walletAddress : Text
) : async ?Text {
// balanceOf(address) = 0x70a08231 + address padded to 32 bytes
let calldata = "0x70a08231000000000000000000000000"
# stripHexPrefix(walletAddress);
let result = await (with cycles = 10_000_000_000)
EvmRpc.eth_call(
#EthMainnet(null),
null,
{
block = null;
transaction = {
to = ?tokenContract;
input = ?calldata;
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");
};
};
};
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 };
};
};
};

The response is a hex-encoded uint256 value. For USDC (6 decimals), divide by 10^6 to get the human-readable balance.

Signing and sending transactions

The EVM RPC canister does not sign transactions. To send a transaction to Ethereum, you must:

  1. Generate an Ethereum address by requesting a threshold ECDSA public key from the IC management canister and deriving the address from it.
  2. Build and sign the transaction using sign_with_ecdsa (threshold ECDSA).
  3. Submit the signed transaction via eth_sendRawTransaction on the EVM RPC canister.

Generate an Ethereum address

Call the management canister’s ecdsa_public_key method to get a public key, then derive the Ethereum address from it (Keccak-256 hash of the uncompressed public key, take last 20 bytes).

import Principal "mo:core/Principal";
import Blob "mo:core/Blob";
persistent actor {
type IC = actor {
ecdsa_public_key : ({
canister_id : ?Principal;
derivation_path : [Blob];
key_id : { curve : { #secp256k1 }; name : Text };
}) -> async ({ public_key : Blob; chain_code : Blob });
};
transient let ic : IC = actor ("aaaaa-aa");
public shared (msg) func getPublicKey() : async Blob {
let caller = Principal.toBlob(msg.caller);
let { public_key } = await ic.ecdsa_public_key({
canister_id = null;
derivation_path = [caller];
key_id = {
curve = #secp256k1;
name = "test_key_1"; // Use "key_1" for production
};
});
public_key;
};
};

Submit a signed transaction

Once you have a signed raw transaction (as a hex string), submit it to Ethereum via eth_sendRawTransaction:

import EvmRpc "canister:evm_rpc";
import Runtime "mo:core/Runtime";
persistent actor {
public func sendRawTransaction(signedTxHex : Text)
: async ?EvmRpc.SendRawTransactionStatus
{
let result = await (with cycles = 10_000_000_000)
EvmRpc.eth_sendRawTransaction(
#EthMainnet(null),
null,
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");
};
};
};
};

For a complete end-to-end example including transaction building, signing, and submission, see the basic_ethereum example.

Querying other EVM chains

Switch chains by changing the service variant. Everything else stays the same:

// Arbitrum
let result = await (with cycles = 10_000_000_000)
EvmRpc.eth_getBlockByNumber(#ArbitrumOne(null), null, #Latest);
// Base
let result = await (with cycles = 10_000_000_000)
EvmRpc.eth_getBlockByNumber(#BaseMainnet(null), null, #Latest);
// Custom RPC endpoint
let result = await (with cycles = 10_000_000_000)
EvmRpc.request(
#Custom({ url = "https://rpc.ankr.com/polygon"; headers = null }),
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}",
1000
);

Cycle costs

Every EVM RPC call requires cycles. The cost depends on the request size, response size, subnet size, and number of providers queried.

Formula:

(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count

Where nodes = 34 (fiduciary subnet) and 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 (approximately $0.0001—$0.001 USD).
  • Use requestCost to get an exact estimate before making a raw JSON-RPC call.
  • The Candid-RPC methods (like eth_getBlockByNumber) automatically retry with larger response sizes if needed, consuming more cycles from your budget.

Collateral cycles

Callers must include at least 0.00028 TC of additional “collateral cycles” to account for possible future API price increases. These are currently fully refunded, but this may change.

Development setup

Project configuration

Add the EVM RPC canister to your icp.yaml as a pre-built canister for local development. On mainnet, it is already deployed at 7hfb6-caaaa-aaaar-qadga-cai and your canister calls it by principal directly.

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 {})"

Local deployment

The icp.yaml above uses environments to separate local and mainnet deployment. Add an environments block to control which canisters are deployed where:

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"

Then deploy locally with:

Terminal window
# Start local replica
icp network start -d
# Deploy both backend and evm_rpc for local development
icp deploy -e local

On mainnet, only the backend is deployed. The EVM RPC canister is already available at 7hfb6-caaaa-aaaar-qadga-cai.

Testing via icp-cli

Query methods (requestCost, getProviders) work directly from the CLI. Update calls require cycles. The CLI cannot attach cycles to a direct canister call. Test those through your backend canister’s wrapper functions instead, since the backend attaches cycles to the inter-canister call internally:

Terminal window
# Query: estimate cost (no cycles needed)
icp canister call evm_rpc requestCost '(
variant { EthMainnet = variant { PublicNode } },
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\"latest\"],\"id\":1}",
1000
)'
# Query: list available providers (no cycles needed)
icp canister call evm_rpc getProviders
# Update call: test via your backend wrapper (attaches cycles internally)
icp canister call backend getLatestBlock

Mainnet deployment

On mainnet, skip deploying the EVM RPC canister. Your backend calls it directly by principal:

Terminal window
icp deploy backend -e ic

Rust type definitions

The examples above use types from evm_rpc_types (MultiRpcResult, RpcServices, Block, etc.) and the lower-level ic-cdk Call API. Add to your Cargo.toml:

[dependencies]
evm_rpc_types = "3"
ic-cdk = "0.20"

Note: For production Rust canisters, the evm_rpc_client crate provides a higher-level typed client that handles cycle attachment, retries, and response decoding automatically. The examples above show the lower-level ic-cdk Call API for clarity. See the evm-rpc skill for a complete evm_rpc_client-based implementation.

Common mistakes

MistakeWhat happensFix
Not sending enough cyclesCall fails silently or trapsStart with 10B cycles, adjust down after verifying
Ignoring Inconsistent variantCanister traps when providers disagreeAlways match all three result arms
Wrong chain variantQueries the wrong chainUse #EthMainnet for Ethereum L1, #ArbitrumOne for Arbitrum, etc.
Omitting null for optional configCandid type mismatchAlways pass null / None for the config parameter
Calling eth_sendRawTransaction without signingTransaction rejectedSign with threshold ECDSA first, then submit the raw signed bytes
Using Cycles.add in mo:coreCompilation errorUse await (with cycles = AMOUNT) canister.method(args)
Response size too smallCall fails on large responsesIncrease max_response_bytes or use Candid-RPC methods (auto-retry)

Next steps