Skip to main content

5.2 Using the EVM RPC canister

Advanced
Tutorial

As you explored in a previous tutorial, the Internet Computer is integrated with the Bitcoin network, allowing for smart contracts to seamlessly communicate from ICP to Bitcoin for multi-chain functionality. ICP also has an integration with Ethereum and EVM networks, but in a different manner than the Bitcoin integration.

The Bitcoin integration is a direct integration, meaning there are nodes running on the ICP mainnet that run the Bitcoin node software. ICP currently does not have any nodes running the Ethereum node software and instead communicates with the Ethereum and EVM-compatible networks through a decentralized RPC integration. To facilitate communication with RPC services, the EVM RPC canister is used.

  Chain Fusion overview

The EVM RPC canister enables your dapp to sign and submit transactions to Ethereum and other EVM networks using HTTPS outcalls and threshold ECDSA signatures.

Through ICP's chain-key cryptography feature, the ETH integration also includes chain-key tokens, similar to the design of ckBTC. The ETH integration expands the possibilities of chain-key tokens to include ckETH and ckERC-20 tokens, including ckUSDC, ckEURC, and ckUSDT.

EVM block explorer example

Open the ICP Ninja EVM block explorer example.

Exploring the project's files

First, let's start by looking at the contents of the project's dfx.json file. This file will contain the following:

{
"canisters": {
"backend": {
"dependencies": ["evm_rpc"],
"main": "backend/app.mo",
"type": "motoko",
"args": "--enhanced-orthogonal-persistence"
},
"frontend": {
"dependencies": ["backend"],
"frontend": {
"entrypoint": "frontend/index.html"
},
"source": ["frontend/dist"],
"type": "assets"
},
"evm_rpc": {
"candid": "https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.did",
"type": "custom",
"specified_id": "7hfb6-caaaa-aaaar-qadga-cai",
"remote": {
"id": {
"ic": "7hfb6-caaaa-aaaar-qadga-cai"
}
},
"wasm": "https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.wasm.gz",
"init_arg": "(record {})"
}
},
"output_env_file": ".env",
"defaults": {
"build": {
"packtool": "mops sources"
}
}

In this file, you can see the definitions for the project's three canisters:

  • frontend: The dapp's frontend canister, which has type "assets" to declare it as an asset canister, and uses the files stored in the dist directory. This canister has a dependency on the backend canister.

  • backend: The dapp's backend canister, which has type "motoko" since it uses Motoko source code stored in the file backend/app.mo. This canister has the dependency of evm_rpc.

  • evm_rpc: This canister is responsible for facilitating communication from the backend canister to RPC services to interact with the Ethereum network. This canister has canister ID 7hfb6-caaaa-aaaar-qadga-cai.

Next, let's take a look at the source code for the backend canister. Open the backend/app.mo file, which will contain the following content. This code has been annotated with notes to explain the code's logic:

backend/app.mo
import EvmRpc "canister:evm_rpc";
import IC "ic:aaaaa-aa";
import Sha256 "mo:sha2/Sha256";
import Base16 "mo:base16/Base16";
import Debug "mo:base/Debug";
import Blob "mo:base/Blob";
import Text "mo:base/Text";
import Cycles "mo:base/ExperimentalCycles";

persistent actor EvmBlockExplorer {
transient let key_name = "test_key_1"; // Use "key_1" for production and "dfx_test_key" locally

public func get_evm_block(height : Nat) : async EvmRpc.Block {
// Ethereum Mainnet RPC providers
// Read more here: https://internetcomputer.org/docs/current/developer-docs/multi-chain/ethereum/evm-rpc/overview#supported-json-rpc-providers
let services : EvmRpc.RpcServices = #EthMainnet(
?[
#Llama,
// #Alchemy,
// #Cloudflare
]
);

// Base Mainnet RPC providers
// Get chain ID and RPC providers from https://chainlist.org/
// let services : EvmRpc.RpcServices = #Custom {
// chainId = 8453;
// services = [
// {url = "https://base.llamarpc.com"; headers = null},
// {url = "https://base-rpc.publicnode.com"; headers = null}
// ];
// };

// Call `eth_getBlockByNumber` RPC method (unused cycles will be refunded)
Cycles.add<system>(10_000_000_000);
let result = await EvmRpc.eth_getBlockByNumber(services, null, #Number(height));

switch result {
// Consistent, successful results.
case (#Consistent(#Ok block)) {
block;
};
// All RPC providers return the same error.
case (#Consistent(#Err error)) {
Debug.trap("Error: " # debug_show error);
};
// Inconsistent results between RPC providers. Should not happen if a single RPC provider is used.
case (#Inconsistent(results)) {
Debug.trap("Inconsistent results" # debug_show results);
};
};
};

public func get_ecdsa_public_key() : async Text {
let { public_key } = await IC.ecdsa_public_key({
canister_id = null;
derivation_path = [];
key_id = { curve = #secp256k1; name = key_name };
});
Base16.encode(public_key);
};

public func sign_message_with_ecdsa(message : Text) : async Text {
let message_hash : Blob = Sha256.fromBlob(#sha256, Text.encodeUtf8(message));
Cycles.add<system>(25_000_000_000);
let { signature } = await IC.sign_with_ecdsa({
message_hash;
derivation_path = [];
key_id = { curve = #secp256k1; name = key_name };
});
Base16.encode(signature);
};

public func get_schnorr_public_key() : async Text {
let { public_key } = await IC.schnorr_public_key({
canister_id = null;
derivation_path = [];
key_id = { algorithm = #ed25519; name = key_name };
});
Base16.encode(public_key);
};

public func sign_message_with_schnorr(message : Text) : async Text {
Cycles.add<system>(25_000_000_000);
let { signature } = await IC.sign_with_schnorr({
message = Text.encodeUtf8(message);
derivation_path = [];
key_id = { algorithm = #ed25519; name = key_name };
aux = null;
});
Base16.encode(signature);
};
};

What this code does

This backend code has five functions:

  • get_evm_block: Returns an Ethereum block according to the inputted block number. It configures the RPC service to make the call through the Llama RPC provider and attaches cycles to the call.

  • get_ecdsa_public_key: Returns the canister's ECDSA public key.

  • sign_message_with_ecdsa: Signs an inputted message using the canister's ECDSA key.

  • get_schnorr_public_key: Returns the canister's Schnorr public key.

  • sign_message_with_schnorr: Signs an inputted message using the canister's Schnorr key.

To use the dapp, click the "Deploy" button in ICP Ninja, then open application's URL returned in the output log:

🥷🚀🎉 Your dapp's Internet Computer URL is ready:
https://zihhr-qiaaa-aaaab-qblla-cai.icp1.io
⏰ Your dapp will be available for 20 minutes

Insert a block number, such as 10001 and click on the "Get block" button to query information about that Ethereum block. If no block number is inserted, the latest block from the Ethereum mainnet is returned.

When this button is clicked, the following is happening in the background:

  • The frontend canister triggers the get_evm_block method of the backend canister.

  • The backend canister uses HTTPS outcalls to send an eth_getBlockByNumber RPC request to an Ethereum JSON-RPC API using the Llama provider. By default, the EVM RPC canister replicates this call across at least 2 other RPC providers.

  • This request involves encoding and decoding ABI, which is the Candid equivalent in the Ethereum ecosystem.

  • The block information is returned to the backend canister. Three responses are returned: one from the specified RPC provider, and two from the other RPC providers that the EVM RPC canister queried automatically for decentralization purposes. The backend canister checks to be sure that all three responses contain the same information.

  • Then, the frontend displays the block information that was returned.

You can also test the buttons for "Get ECDSA public key" or "Get Schnorr public key" buttons to return the canister's public keys, or enter and sign messages with either signature type.

Calling the EVM RPC canister from the CLI

You can make calls directly to the EVM RPC canister from the CLI. For example, to get the latest Ethereum gas fee information, use the dfx command:

export IDENTITY=default
export CYCLES=2000000000
export WALLET=$(dfx identity get-wallet)
export RPC_SOURCE=EthMainnet
export RPC_CONFIG=null

dfx canister call evm_rpc eth_feeHistory "(variant {$RPC_SOURCE}, $RPC_CONFIG, record {blockCount = 3; newestBlock = variant {Latest}})" --with-cycles=$CYCLES --wallet=$WALLET

If the results from each RPC provider match, the result returned will be 'Consistent':

(
  variant {
    Consistent = variant {
      Ok = opt record {
        reward = vec {};
        gasUsedRatio = vec {
          0.4901801333333333 : float64;
          0.2692428 : float64;
          0.6662872333333333 : float64;
        };
        oldestBlock = 20_594_047 : nat;
        baseFeePerGas = vec {
          2_790_453_437 : nat;
          2_783_602_967 : nat;
          2_623_018_861 : nat;
          2_732_062_498 : nat;
        };
      }
    }
  },
)

If the results returned are 'Inconsistent,' each individual response will be returned:

(
  variant {
    Inconsistent = vec {
      record {
        variant { EthMainnet = variant { Ankr } };
        variant {
          Ok = opt record {
            reward = vec {};
            gasUsedRatio = vec {
              0.4223029666666666 : float64;
              0.4901801333333333 : float64;
              0.2692428 : float64;
            };
            oldestBlock = 20_594_046 : nat;
            baseFeePerGas = vec {
              2_845_729_624 : nat;
              2_790_453_437 : nat;
              2_783_602_967 : nat;
              2_623_018_861 : nat;
            };
          }
        };
      };
      record {
        variant { EthMainnet = variant { PublicNode } };
        variant {
          Err = variant {
            ProviderError = variant {
              TooFewCycles = record {
                expected = 555_296_000 : nat;
                received = 449_408_000 : nat;
              }
            }
          }
        };
      };
      record {
        variant { EthMainnet = variant { Cloudflare } };
        variant {
          Ok = opt record {
            reward = vec {};
            gasUsedRatio = vec {
              0.4223029666666666 : float64;
              0.4901801333333333 : float64;
              0.2692428 : float64;
            };
            oldestBlock = 20_594_046 : nat;
            baseFeePerGas = vec {
              2_845_729_624 : nat;
              2_790_453_437 : nat;
              2_783_602_967 : nat;
              2_623_018_861 : nat;
            };
          }
        };
      };
    }
  },
)

You can also sign and submit transactions directly to Ethereum with a command such as:

export IDENTITY=default
export CYCLES=2000000000
export WALLET=$(dfx identity get-wallet)
export RPC_SOURCE=EthMainnet
export RPC_CONFIG=null

dfx canister call evm_rpc eth_sendRawTransaction "(variant {$RPC_SOURCE}, $RPC_CONFIG, \"0xf86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83\")" --with-cycles=$CYCLES --wallet=$WALLET

Your transaction ID will be returned from the RPC provider. For example, here is the transaction ID returned from PublicNode:

  record {
    variant { EthMainnet = variant { PublicNode } };
    variant {
      Ok = variant {
        Ok = opt "0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788"
      }
    };
  };

Some JSON-RPC APIs may only return a NonceTooLow status when successfully submitting a transaction. This is because during the HTTP outcall consensus, only the first request is successful, while the others reply with a duplicate transaction status.

Resources

ICP AstronautNeed help?

Did you get stuck somewhere in this tutorial, or do you feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out: