Talking to the Internet Computer from an actor
An actor hosted by moxzid (or a moxzi game, or a browser tab) can be a client of the Internet Computer: mo:ic-agent speaks the IC's HTTP interface — signed envelopes, request ids, read_state certificates — from Motoko, over the HTTPS outcalls the host already provides. It is the same thing agent-js or ic-agent (Rust) do, in the language the actor is written in.
actor "aaaaa-aa" and actor(p) still mean "an actor in this runtime". The agent is how an off-chain actor reaches a canister that lives on the IC.
A query#
import Agent "mo:ic-agent";
persistent actor {
transient let agent = Agent.Agent(Agent.defaults("https://icp-api.io", #anonymous));
let ledger = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");
public func ledgerName() : async Text {
switch (await* agent.queryCall(ledger, "icrc1_name", to_candid())) {
case (#ok reply) { let ?name : ?Text = from_candid(reply) else return "?"; name };
case (#err e) debug_show e;
}
};
};
mops add ic-agent; the transport is http_request on the management canister, so moxzid must not be started with --no-outcalls, and a game declares capabilities: [http].
An update, without blocking#
An update call is two operations. submit sends the envelope and returns the request id at once; poll is one read_state round trip:
let #ok rid = await* agent.submit(canister, "inc", to_candid()) else return;
// … on a later message / tick:
switch (await* agent.poll(canister, rid)) {
case (#ok(#replied bytes)) { … };
case (#ok(#pending _)) { /* ask again later */ };
case (#ok(#rejected r)) { /* r.code : #canisterReject | #sysTransient | … */ };
case (#ok(#unknown)) { /* not seen yet, or expired */ };
case _ {};
};
call(canister, method, arg, maxPolls) composes the two for code that can afford to wait — it polls up to maxPolls times and hands back #err(#pending rid) if the call is still in flight, so the caller can keep polling with the id.
Typed façades#
moxzi agent-bindings ledger.did --name Ledger -o Ledger.mo writes a Motoko module with every type of the interface and a Facade class over an agent:
import Ledger "Ledger";
transient let ledger = Ledger.connectText(agent, "ryjl3-tyaaa-aaaaa-aaaba-cai");
public func balance(owner : Principal) : async Nat {
switch (await* ledger.icrc1_balance_of({ owner; subaccount = null })) { case (#ok n) n; case (#err _) 0 }
};
A façade is a proxy object, not an actor reference — the type says so. Query methods go out as queries; update methods as calls, and also as m_submit / m_poll for code that keeps ticking. Labels Motoko cannot write degrade to Any with a NOTE in the module header.
Identities#
#anonymous | the anonymous principal 2vxsx-fae |
Identity.ed25519(seed) | a 32-byte seed; signs the request bytes directly |
Identity.secp256k1(secret) | a 32-byte secret; ECDSA over sha256, r ‖ s; builds the generator table once per identity — keep it |
#signer { publicKey; sign } | any DER public key with a host-supplied signing function (a key moxzid holds, a hardware key) |
Identity.delegated(rootKey, chain, session) | an Internet Identity delegation chain: the sender principal is the root key's, the session key signs |
The principal is the self-authenticating one the IC derives (sha224(DER) ‖ 0x02); a canister's caller sees exactly it.
Everything that comes back is verified#
With Config.verify = #required (the default) the agent trusts nothing a node sends until it is checked against the root key (Agent.MAINNET_ROOT_KEY, or the one status() reports for a local replica):
- Certificates (
call,poll,readState,readSubnetState,moduleHash,controllers,metadata): the BLS signature over the tree's root hash, at most one subnet delegation scoped to the canister or subnet asked about, a well-formed tree, and a/timewithin 5 minutes of now. - Query replies and rejections: every node signature (Ed25519 over the
ic-responsehash) against that node's key from a verified/subnetcertificate, with fresh timestamps. Node keys are cached per subnet for 5 minutes.
#skip turns verification off and prints a loud line for every response it skips.
| Step | Wasm instructions | How often |
|---|---|---|
| One certificate (one BLS signature) | 1.44 billion | per certificate; two under a delegation |
| A delegated subnet's node keys | 2.96 billion | once per subnet per 5 minutes |
| One query response's node signature | 155 million | per query |
The agent speaks the current endpoints (v3 query, v3 read_state, v4 synchronous call) and falls back once, visibly (apiVersions()), on a gateway that lacks them. Calls to the management canister are routed to the canister their Candid argument names.
Proof#
scripts/ic_agent_gate.sh runs four layers:
- Known-answer tests, built by both compilers with the same result.
- The interface spec against mainnet (
test/SpecVectors.mo, 121 checks): real mainnet query responses and certificates, root and delegated subnets, a signed rejection, every forgery and staleness case, and 64 OpenSSL Ed25519 keys and signatures. - Live against PocketIC, with an NNS plus application-subnet topology so answers come under a real delegation. It covers verified queries and rejections, calls, polling, canister information, management routing, canister signatures and certified data, with agent-js as an oracle.
- Live against mainnet: verified queries and certified reads on the root subnet and on a delegated subnet.
The demo: a high-score table on mainnet#
demos/highscore/ is the whole shape in three files: HighScore.mo (the canister), gen/HighScore.mo (its façade, generated by moxzi agent-bindings) and Board.mo (the game side, a moxzid actor that posts scores and reads the board). Run scripts/highscore_demo_gate.sh to see it against a PocketIC live gateway; deploy the table with scripts/deploy_highscore_mainnet.sh and the same gate reads it from mainnet.