﻿# Ethereum Integration

> For the complete documentation index, see [llms.txt](/llms.txt)

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](../../concepts/chain-fusion.md).

## 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
participant "Your Canister" as Canister
participant "EVM RPC Canister" as EVM
participant "Provider 1" as P1
participant "Provider 2" as P2
participant "Provider N" as PN

Canister -> EVM: eth_getBlockByNumber(chain, args) + cycles
EVM -> P1: JSON-RPC (HTTPS outcall)
EVM -> P2: JSON-RPC (HTTPS outcall)
EVM -> PN: JSON-RPC (HTTPS outcall)
P1 --> EVM: response
P2 --> EVM: response
PN --> EVM: response
note right of EVM: consensus check (≥2/3 nodes agree)
EVM --> Canister: Consistent(result) + refund excess cycles
```

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](../backends/https-outcalls.md).
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.

| Chain | Variant (Motoko / Rust) | Chain ID |
|---|---|---|
| Ethereum Mainnet | `#EthMainnet` / `EthMainnet` | 1 |
| Ethereum Sepolia | `#EthSepolia` / `EthSepolia` | 11155111 |
| Arbitrum One | `#ArbitrumOne` / `ArbitrumOne` | 42161 |
| Base Mainnet | `#BaseMainnet` / `BaseMainnet` | 8453 |
| Optimism Mainnet | `#OptimismMainnet` / `OptimismMainnet` | 10 |
| Custom | `#Custom` / `Custom` | any |

**Built-in providers** (no API key needed):

| 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 |

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)

#### Motoko

```motoko
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");
      };
    };
  };
};
```

#### Rust

```rust
use evm_rpc_types::{Block, BlockTag, MultiRpcResult, RpcServices};
use ic_cdk::call::Call;
use ic_cdk::update;

#[update]
async fn get_latest_block() -> Block {
    let cycles: u128 = 10_000_000_000;

    let (result,): (MultiRpcResult<Block>,) =
        Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
            .with_args(&(
                RpcServices::EthMainnet(None),
                None::<()>,
                BlockTag::Latest,
            ))
            .with_cycles(cycles)
            .await
            .expect("Failed to call EVM RPC canister")
            .candid_tuple()
            .expect("Failed to decode response");

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

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.

#### Motoko

```motoko
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);
      };
    };
  };
};
```

#### Rust

```rust
use candid::Principal;
use evm_rpc_types::{EthMainnetService, RpcError, RpcService};
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()
}

#[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)),
    }
}
```

### Read an ERC-20 token balance

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

#### Motoko

```motoko
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 };
    };
  };
};
```

#### Rust

```rust
use evm_rpc_types::{EthMainnetService, RpcError, RpcService};
use ic_cdk::call::Call;
use ic_cdk::update;

#[update]
async fn get_erc20_balance(
    token_contract: String,
    wallet_address: String,
) -> String {
    // balanceOf(address) selector: 0x70a08231
    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)),
    }
}
```

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).

#### Motoko

```motoko
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;
  };
};
```

#### Rust

```rust
use ic_cdk::management_canister::{ecdsa_public_key, EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs};
use ic_cdk::update;

#[update]
async fn get_public_key() -> Vec<u8> {
    let request = EcdsaPublicKeyArgs {
        canister_id: None,
        derivation_path: vec![],
        key_id: EcdsaKeyId {
            curve: EcdsaCurve::Secp256k1,
            name: "test_key_1".to_string(), // Use "key_1" for production
        },
    };

    let response = ecdsa_public_key(&request)
        .await
        .expect("ecdsa_public_key failed");

    response.public_key
}
```

To derive the Ethereum address from the public key, hash the uncompressed key with Keccak-256 and take the last 20 bytes. See the [basic_ethereum example](https://github.com/dfinity/examples/tree/master/rust/basic_ethereum) for a complete implementation including address derivation and transaction signing.

### Submit a signed transaction

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

#### Motoko

```motoko
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");
      };
    };
  };
};
```

#### Rust

```rust
#[update]
async fn send_raw_transaction(
    signed_tx_hex: String,
) -> SendRawTransactionStatus {
    let cycles: u128 = 10_000_000_000;

    let (result,): (MultiRpcResult<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 {
        MultiRpcResult::Consistent(Ok(status)) => status,
        MultiRpcResult::Consistent(Err(err)) => {
            ic_cdk::trap(&format!("RPC error: {:?}", err))
        }
        MultiRpcResult::Inconsistent(_) => {
            ic_cdk::trap("Providers returned inconsistent results")
        }
    }
}
```

For a complete end-to-end example including transaction building, signing, and submission, see the [basic_ethereum example](https://github.com/dfinity/examples/tree/master/rust/basic_ethereum).

## Querying other EVM chains

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

### Motoko

```motoko
// 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
  );
```

### Rust

```rust
use evm_rpc_types::{Block, BlockTag, CustomRpcService, MultiRpcResult, RpcError, RpcService, RpcServices};
use ic_cdk::call::Call;

// Arbitrum
let (result,): (MultiRpcResult<Block>,) =
    Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber")
        .with_args(&(
            RpcServices::ArbitrumOne(None),
            None::<()>,
            BlockTag::Latest,
        ))
        .with_cycles(10_000_000_000_u128)
        .await
        .expect("call failed")
        .candid_tuple()
        .expect("decode failed");

// Custom RPC endpoint
let (result,): (Result<String, RpcError>,) =
    Call::unbounded_wait(evm_rpc_id(), "request")
        .with_args(&(
            RpcService::Custom(CustomRpcService {
                url: "https://rpc.ankr.com/polygon".to_string(),
                headers: None,
            }),
            r#"{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}"#.to_string(),
            1000_u64,
        ))
        .with_cycles(10_000_000_000_u128)
        .await
        .expect("call failed")
        .candid_tuple()
        .expect("decode failed");
```

## 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:**

```text
(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.

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

### 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:

```yaml
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:

```bash
# 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:

```bash
# 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:

```bash
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`:

```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](https://skills.internetcomputer.org) for a complete `evm_rpc_client`-based implementation.

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Not sending enough cycles | Call fails silently or traps | Start with 10B cycles, adjust down after verifying |
| Ignoring `Inconsistent` variant | Canister traps when providers disagree | Always match all three result arms |
| Wrong chain variant | Queries the wrong chain | Use `#EthMainnet` for Ethereum L1, `#ArbitrumOne` for Arbitrum, etc. |
| Omitting `null` for optional config | Candid type mismatch | Always pass `null` / `None` for the config parameter |
| Calling `eth_sendRawTransaction` without signing | Transaction rejected | Sign with threshold ECDSA first, then submit the raw signed bytes |
| Using `Cycles.add` in mo:core | Compilation error | Use `await (with cycles = AMOUNT) canister.method(args)` |
| Response size too small | Call fails on large responses | Increase `max_response_bytes` or use Candid-RPC methods (auto-retry) |

## Next steps

- [Bitcoin integration](bitcoin.md): similar patterns for BTC using the Bitcoin API
- [Chain-key tokens](../digital-assets/chain-key-tokens.md): learn about ckETH and other chain-key tokens backed 1:1 by native assets
- [Chain Fusion concepts](../../concepts/chain-fusion.md): understand how ICP connects to external blockchains
- [HTTPS outcalls](../backends/https-outcalls.md): the underlying mechanism the EVM RPC canister uses
- [basic_ethereum example](https://github.com/dfinity/examples/tree/master/rust/basic_ethereum): complete end-to-end Rust example with address generation, signing, and transaction submission
- [EVM RPC canister source](https://github.com/dfinity/evm-rpc-canister): canister source code and Candid interface
