Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions motoko/evm_block_explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

The EVM Block Explorer example demonstrates how an ICP canister can fetch block data directly from Ethereum and other EVM-compatible chains. Using HTTPS outcalls via the [EVM RPC canister](https://github.com/dfinity/evm-rpc-canister), canisters on ICP can read on-chain data without a bridge or oracle. The same pattern applies to any EVM-compatible chain supported by the EVM RPC canister.

The backend reaches the EVM RPC canister through the typed import `import EvmRpc "canister:evm_rpc"` in `backend/EvmRpcApi.mo` — no RPC actor type is hand-written. The import is typed against the EVM RPC canister's committed Candid interface (`candid/evm_rpc.did`), and the `--actor-env-alias` flag in `mops.toml` binds it to the `PUBLIC_CANISTER_ID:evm_rpc` environment variable that icp-cli injects (the local `evm_rpc` canister when developing, the shared `7hfb6-caaaa-aaaar-qadga-cai` on mainnet). The principal is resolved at canister install/upgrade; no principal is compiled into the Wasm, so the same artifact runs in every environment.

<!--
## Deploying from ICP Ninja

Expand Down Expand Up @@ -66,6 +68,23 @@ If you modify the backend's public API, regenerate the `.did` file:
mops generate candid backend
```

`candid/evm_rpc.did` is the **EVM RPC canister's own interface**, not the backend's — the `canister:evm_rpc` import is typed against it. The `candid/` directory holds the interfaces of external canisters this project calls (as opposed to `backend/backend.did`, which is this project's own interface). These are not produced by `mops generate candid` — each is the Candid interface of an external canister. To refresh one (e.g. after bumping the EVM RPC release), get it straight from the canister with either of:

**From mainnet** — the live shared EVM RPC canister. One command, no files to handle:

```bash
icp canister metadata 7hfb6-caaaa-aaaar-qadga-cai candid:service -e ic > candid/evm_rpc.did
```

**From the pinned Wasm** — matches exactly what deploys locally. The Wasm is the pre-built artifact pinned in this project's `icp.yaml` (the `evm_rpc` canister's `build.steps[].url`); download and unpack it, then extract its interface:

```bash
curl -sSL https://github.com/dfinity/evm-rpc-canister/releases/download/evm_rpc-v2.8.0/evm_rpc.wasm.gz | gunzip > evm_rpc.wasm
ic-wasm evm_rpc.wasm metadata candid:service > candid/evm_rpc.did
```

Both give the same interface as long as `icp.yaml` pins the release that is live on mainnet.

## RPC providers and API keys

The example uses [PublicNode](https://ethereum-rpc.publicnode.com) by default — a free, no-registration provider that works out of the box locally and on mainnet. This is sufficient for getting started and automated testing.
Expand Down
174 changes: 13 additions & 161 deletions motoko/evm_block_explorer/backend/EvmRpcApi.mo
Original file line number Diff line number Diff line change
@@ -1,179 +1,31 @@
import Runtime "mo:core/Runtime";
import EvmRpc "canister:evm_rpc";

module {
// Inline actor type for the EVM RPC canister, exposing only eth_getBlockByNumber.
// Full Candid interface: https://github.com/dfinity/evm-rpc-canister/blob/main/candid/evm_rpc.did
// The EVM RPC canister is imported by name via `canister:evm_rpc`, typed
// against candid/evm_rpc.did — so the request/response types below are the
// canister's own (e.g. EvmRpc.Block, EvmRpc.RpcServices), not hand-written.
// icp-cli injects its principal as PUBLIC_CANISTER_ID:evm_rpc at deploy time;
// the mops.toml `--actor-env-alias` flag binds the import to that variable.

public type Block = {
miner : Text;
totalDifficulty : ?Nat;
receiptsRoot : Text;
stateRoot : Text;
hash : Text;
difficulty : ?Nat;
size : Nat;
uncles : [Text];
baseFeePerGas : ?Nat;
extraData : Text;
transactionsRoot : ?Text;
sha3Uncles : Text;
nonce : Nat;
number : Nat;
timestamp : Nat;
transactions : [Text];
gasLimit : Nat;
logsBloom : Text;
parentHash : Text;
gasUsed : Nat;
mixHash : Text;
};

type BlockTag = {
#Earliest;
#Safe;
#Finalized;
#Latest;
#Number : Nat;
#Pending;
};

type HttpHeader = { value : Text; name : Text };
type RpcApi = { url : Text; headers : ?[HttpHeader] };

type EthMainnetService = {
#Alchemy;
#Ankr;
#BlockPi;
#Cloudflare;
#PublicNode;
#Llama;
};

type EthSepoliaService = {
#Alchemy;
#Ankr;
#BlockPi;
#PublicNode;
#Sepolia;
};

type L2MainnetService = {
#Alchemy;
#Ankr;
#BlockPi;
#PublicNode;
#Llama;
};

type RpcServices = {
#Custom : { chainId : Nat64; services : [RpcApi] };
#EthSepolia : ?[EthSepoliaService];
#EthMainnet : ?[EthMainnetService];
#ArbitrumOne : ?[L2MainnetService];
#BaseMainnet : ?[L2MainnetService];
#OptimismMainnet : ?[L2MainnetService];
};

type RpcService = {
#Provider : Nat64;
#Custom : RpcApi;
#EthSepolia : EthSepoliaService;
#EthMainnet : EthMainnetService;
#ArbitrumOne : L2MainnetService;
#BaseMainnet : L2MainnetService;
#OptimismMainnet : L2MainnetService;
};

type RejectionCode = {
#NoError;
#CanisterError;
#SysTransient;
#DestinationInvalid;
#Unknown;
#SysFatal;
#CanisterReject;
};

type JsonRpcError = { code : Int64; message : Text };

type ProviderError = {
#TooFewCycles : { expected : Nat; received : Nat };
#MissingRequiredProvider;
#ProviderNotFound;
#NoPermission;
#InvalidRpcConfig : Text;
};

type HttpOutcallError = {
#IcError : { code : RejectionCode; message : Text };
#InvalidHttpJsonRpcResponse : { status : Nat16; body : Text; parsingError : ?Text };
};

type ValidationError = {
#Custom : Text;
#InvalidHex : Text;
};

type RpcError = {
#JsonRpcError : JsonRpcError;
#ProviderError : ProviderError;
#ValidationError : ValidationError;
#HttpOutcallError : HttpOutcallError;
};

type GetBlockByNumberResult = { #Ok : Block; #Err : RpcError };

type MultiGetBlockByNumberResult = {
#Consistent : GetBlockByNumberResult;
#Inconsistent : [(RpcService, GetBlockByNumberResult)];
};

// Controls how the EVM RPC canister aggregates responses from multiple providers.
// - responseSizeEstimate: hint for the expected response size in bytes (affects cycles cost).
// Leave null to use the canister's built-in default.
// - responseConsensus: how providers must agree before a result is accepted.
// #Equality requires all providers to return identical responses (default).
// #Threshold { total; min } requires at least `min` out of `total` providers to agree.
// Leave null to use #Equality.
// Pass null for the entire RpcConfig to use all defaults — this is the right choice for most callers.
type ConsensusStrategy = {
#Equality;
#Threshold : { total : ?Nat; min : Nat };
};
type RpcConfig = { responseSizeEstimate : ?Nat64; responseConsensus : ?ConsensusStrategy };

type EvmRpcActor = actor {
eth_getBlockByNumber : (RpcServices, ?RpcConfig, BlockTag) -> async MultiGetBlockByNumberResult;
};

// The result type exposed to the main actor — matches the Rust variant names for cross-language consistency.
public type EvmBlockResult = { #Ok : Block; #Err : Text };

// Returns the EVM RPC canister actor, resolved at runtime from the PUBLIC_CANISTER_ID:evm_rpc
// environment variable. icp-cli sets this automatically at deploy time:
// - locally: the principal of the locally deployed evm_rpc canister
// - on ICP mainnet (ic environment): 7hfb6-caaaa-aaaar-qadga-cai
func evmRpc<system>() : EvmRpcActor {
let ?id = Runtime.envVar<system>("PUBLIC_CANISTER_ID:evm_rpc") else
Runtime.trap("PUBLIC_CANISTER_ID:evm_rpc not set — run icp deploy");
actor(id) : EvmRpcActor;
};
// The result type exposed to the main actor — matches the Rust variant names
// for cross-language consistency, and carries the RPC canister's Block type.
public type EvmBlockResult = { #Ok : EvmRpc.Block; #Err : Text };

// Fetches the Ethereum mainnet block at the given height.
// Uses PublicNode by default — no API key required, works locally and on mainnet.
// For production deployments requiring premium providers (Alchemy, Ankr, BlockPi),
// configure API keys via the EVM RPC canister, then pass null to use all configured
// providers for better consensus: #EthMainnet(null)
public func getBlock<system>(height : Nat) : async EvmBlockResult {
let services : RpcServices = #EthMainnet(?[#PublicNode]);
public func getBlock(height : Nat) : async EvmBlockResult {
let services : EvmRpc.RpcServices = #EthMainnet(?[#PublicNode]);

// To query a different chain, use #Custom instead:
// let services : RpcServices = #Custom {
// let services : EvmRpc.RpcServices = #Custom {
// chainId = 8453; // Base Mainnet — see https://chainlist.org/ for chain IDs
// services = [{ url = "https://base-rpc.publicnode.com"; headers = null }];
// };

let result = await (with cycles = 10_000_000_000) evmRpc<system>().eth_getBlockByNumber(services, null, #Number height);
let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber(services, null, #Number height);

switch result {
case (#Consistent(#Ok block)) { #Ok block };
Expand Down
Loading
Loading