Skip to content

Commit b562697

Browse files
marc0oloclaude
andauthored
refactor(motoko): canister: import + --actor-env-alias for external-canister calls (ic-pos, evm_block_explorer, llm_chatbot) (#1453)
* refactor(motoko): use canister: import + --actor-env-alias for external-canister calls Apply the Tier 1 pattern (canister:<name> import backed by moc's --actor-env-alias) to the three Motoko examples that call an external canister already named + env-injected in icp.yaml: - ic-pos → icrc1_ledger (drops the hand-written Ledger actor type and the Account/Transfer/Transaction subtypes) - evm_block_explorer → evm_rpc (drops the ~140-line EvmRpcApi type surface: RpcServices/RpcConfig/BlockTag/errors/actor) - llm_chatbot → llm (drops the LlmActor/Request/Response boilerplate; keeps the public message types, which are the backend's own API and are structurally identical to the LLM canister's) For each backend: - import the target by name and drop the actor type + Runtime.envVar + actor(id) plumbing (and the now-unneeded <system> params); - commit the target's Candid interface, extracted from the exact pre-built Wasm pinned in icp.yaml via `ic-wasm <wasm> metadata candid:service` (icrc1_ledger.did, evm_rpc.did, llm.did); - wire the import in mops.toml with `--actor-env-alias <alias> PUBLIC_CANISTER_ID:<name> <did-path>` (per-canister args replace the global [moc].args, so the global flags are repeated). The principal is still resolved from PUBLIC_CANISTER_ID:<name>, now at canister install/upgrade rather than per call. Each backend's public backend.did is byte-identical after the change (verified with `mops generate candid backend`), so the frontends are unaffected. READMEs document the typed import and, for these external targets, that the committed .did is refreshed by re-extracting it from the pinned Wasm (not `mops generate candid`). Verified: all three build with `mops build`; evm_block_explorer deployed locally and `get_evm_block(1)` returns Ethereum mainnet block 1 with the correct hash and miner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(motoko): move external-canister interfaces into a project-level candid/ dir Address review feedback on the Tier 2 examples: keep a project's own interface with its code (backend/backend.did, generated by `mops generate candid`), and move the interfaces of *external* canisters the project calls into a shared, project-root `candid/` directory: - ic-pos: backend/icrc1_ledger.did -> candid/icrc1_ledger.did - evm_block_explorer: backend/evm_rpc.did -> candid/evm_rpc.did - llm_chatbot: backend/llm.did -> candid/llm.did This separates first-party from vendored interfaces (a 586-line ledger .did no longer sits next to the 20-line backend.did) and scales to projects with several canisters that share an external interface — one copy in candid/, referenced by each canister's mops.toml, rather than a per-backend folder that would duplicate it. Updates the `--actor-env-alias` did-paths in mops.toml, the in-code comments, and the READMEs (which now explain the candid/ convention and drop the vague "different kind of file" wording). Rebuilt all three; each backend.did is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(motoko): explain both ways to refresh an external canister's .did The refresh instructions previously said "re-extract it from that Wasm" but never said where the Wasm comes from — it is only pinned by URL in icp.yaml, not present in the project. Rewrite the note in all three Tier 2 examples to give two clear, self-contained options: - from mainnet, by the canister's principal: `icp canister metadata <principal> candid:service -e ic` - from the pinned Wasm: download the exact pre-built artifact pinned in icp.yaml (build.steps[].url), unpack it, then `ic-wasm ... candid:service` Both were verified to reproduce the committed candid/*.did byte-for-byte. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent db0ca74 commit b562697

12 files changed

Lines changed: 1115 additions & 224 deletions

File tree

motoko/evm_block_explorer/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
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.
88

9+
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.
10+
911
<!--
1012
## Deploying from ICP Ninja
1113
@@ -66,6 +68,23 @@ If you modify the backend's public API, regenerate the `.did` file:
6668
mops generate candid backend
6769
```
6870

71+
`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:
72+
73+
**From mainnet** — the live shared EVM RPC canister. One command, no files to handle:
74+
75+
```bash
76+
icp canister metadata 7hfb6-caaaa-aaaar-qadga-cai candid:service -e ic > candid/evm_rpc.did
77+
```
78+
79+
**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:
80+
81+
```bash
82+
curl -sSL https://github.com/dfinity/evm-rpc-canister/releases/download/evm_rpc-v2.8.0/evm_rpc.wasm.gz | gunzip > evm_rpc.wasm
83+
ic-wasm evm_rpc.wasm metadata candid:service > candid/evm_rpc.did
84+
```
85+
86+
Both give the same interface as long as `icp.yaml` pins the release that is live on mainnet.
87+
6988
## RPC providers and API keys
7089

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

motoko/evm_block_explorer/backend/EvmRpcApi.mo

Lines changed: 13 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,31 @@
1-
import Runtime "mo:core/Runtime";
1+
import EvmRpc "canister:evm_rpc";
22

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

7-
public type Block = {
8-
miner : Text;
9-
totalDifficulty : ?Nat;
10-
receiptsRoot : Text;
11-
stateRoot : Text;
12-
hash : Text;
13-
difficulty : ?Nat;
14-
size : Nat;
15-
uncles : [Text];
16-
baseFeePerGas : ?Nat;
17-
extraData : Text;
18-
transactionsRoot : ?Text;
19-
sha3Uncles : Text;
20-
nonce : Nat;
21-
number : Nat;
22-
timestamp : Nat;
23-
transactions : [Text];
24-
gasLimit : Nat;
25-
logsBloom : Text;
26-
parentHash : Text;
27-
gasUsed : Nat;
28-
mixHash : Text;
29-
};
30-
31-
type BlockTag = {
32-
#Earliest;
33-
#Safe;
34-
#Finalized;
35-
#Latest;
36-
#Number : Nat;
37-
#Pending;
38-
};
39-
40-
type HttpHeader = { value : Text; name : Text };
41-
type RpcApi = { url : Text; headers : ?[HttpHeader] };
42-
43-
type EthMainnetService = {
44-
#Alchemy;
45-
#Ankr;
46-
#BlockPi;
47-
#Cloudflare;
48-
#PublicNode;
49-
#Llama;
50-
};
51-
52-
type EthSepoliaService = {
53-
#Alchemy;
54-
#Ankr;
55-
#BlockPi;
56-
#PublicNode;
57-
#Sepolia;
58-
};
59-
60-
type L2MainnetService = {
61-
#Alchemy;
62-
#Ankr;
63-
#BlockPi;
64-
#PublicNode;
65-
#Llama;
66-
};
67-
68-
type RpcServices = {
69-
#Custom : { chainId : Nat64; services : [RpcApi] };
70-
#EthSepolia : ?[EthSepoliaService];
71-
#EthMainnet : ?[EthMainnetService];
72-
#ArbitrumOne : ?[L2MainnetService];
73-
#BaseMainnet : ?[L2MainnetService];
74-
#OptimismMainnet : ?[L2MainnetService];
75-
};
76-
77-
type RpcService = {
78-
#Provider : Nat64;
79-
#Custom : RpcApi;
80-
#EthSepolia : EthSepoliaService;
81-
#EthMainnet : EthMainnetService;
82-
#ArbitrumOne : L2MainnetService;
83-
#BaseMainnet : L2MainnetService;
84-
#OptimismMainnet : L2MainnetService;
85-
};
86-
87-
type RejectionCode = {
88-
#NoError;
89-
#CanisterError;
90-
#SysTransient;
91-
#DestinationInvalid;
92-
#Unknown;
93-
#SysFatal;
94-
#CanisterReject;
95-
};
96-
97-
type JsonRpcError = { code : Int64; message : Text };
98-
99-
type ProviderError = {
100-
#TooFewCycles : { expected : Nat; received : Nat };
101-
#MissingRequiredProvider;
102-
#ProviderNotFound;
103-
#NoPermission;
104-
#InvalidRpcConfig : Text;
105-
};
106-
107-
type HttpOutcallError = {
108-
#IcError : { code : RejectionCode; message : Text };
109-
#InvalidHttpJsonRpcResponse : { status : Nat16; body : Text; parsingError : ?Text };
110-
};
111-
112-
type ValidationError = {
113-
#Custom : Text;
114-
#InvalidHex : Text;
115-
};
116-
117-
type RpcError = {
118-
#JsonRpcError : JsonRpcError;
119-
#ProviderError : ProviderError;
120-
#ValidationError : ValidationError;
121-
#HttpOutcallError : HttpOutcallError;
122-
};
123-
124-
type GetBlockByNumberResult = { #Ok : Block; #Err : RpcError };
125-
126-
type MultiGetBlockByNumberResult = {
127-
#Consistent : GetBlockByNumberResult;
128-
#Inconsistent : [(RpcService, GetBlockByNumberResult)];
129-
};
130-
131-
// Controls how the EVM RPC canister aggregates responses from multiple providers.
132-
// - responseSizeEstimate: hint for the expected response size in bytes (affects cycles cost).
133-
// Leave null to use the canister's built-in default.
134-
// - responseConsensus: how providers must agree before a result is accepted.
135-
// #Equality requires all providers to return identical responses (default).
136-
// #Threshold { total; min } requires at least `min` out of `total` providers to agree.
137-
// Leave null to use #Equality.
138-
// Pass null for the entire RpcConfig to use all defaults — this is the right choice for most callers.
139-
type ConsensusStrategy = {
140-
#Equality;
141-
#Threshold : { total : ?Nat; min : Nat };
142-
};
143-
type RpcConfig = { responseSizeEstimate : ?Nat64; responseConsensus : ?ConsensusStrategy };
144-
145-
type EvmRpcActor = actor {
146-
eth_getBlockByNumber : (RpcServices, ?RpcConfig, BlockTag) -> async MultiGetBlockByNumberResult;
147-
};
148-
149-
// The result type exposed to the main actor — matches the Rust variant names for cross-language consistency.
150-
public type EvmBlockResult = { #Ok : Block; #Err : Text };
151-
152-
// Returns the EVM RPC canister actor, resolved at runtime from the PUBLIC_CANISTER_ID:evm_rpc
153-
// environment variable. icp-cli sets this automatically at deploy time:
154-
// - locally: the principal of the locally deployed evm_rpc canister
155-
// - on ICP mainnet (ic environment): 7hfb6-caaaa-aaaar-qadga-cai
156-
func evmRpc<system>() : EvmRpcActor {
157-
let ?id = Runtime.envVar<system>("PUBLIC_CANISTER_ID:evm_rpc") else
158-
Runtime.trap("PUBLIC_CANISTER_ID:evm_rpc not set — run icp deploy");
159-
actor(id) : EvmRpcActor;
160-
};
10+
// The result type exposed to the main actor — matches the Rust variant names
11+
// for cross-language consistency, and carries the RPC canister's Block type.
12+
public type EvmBlockResult = { #Ok : EvmRpc.Block; #Err : Text };
16113

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

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

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

17830
switch result {
17931
case (#Consistent(#Ok block)) { #Ok block };

0 commit comments

Comments
 (0)