Candid encodingalpha
How do I encode arguments and decode replies correctly?
moxzi-web speaks raw Candid bytes, because that is what the Internet Computer speaks: a call carries an export name (canister_update greet) and a byte string. The constraint that follows: the wire format carries field hashes, not field names, so a decode without a type recovers numbers where your record had labels.
The typed path#
Pass the idlFactory that dfx generate wrote and the library builds one JS method per service method. It reads func.argTypes / func.retTypes from the factory, encodes with @dfinity/candid's IDL.encode, and decodes the reply with IDL.decode:
import { idlFactory } from './declarations/greeter/greeter.did.js';
const greeter = await moxzi.install({ name: 'greeter', wasm: './greet.wasm', idlFactory });
await greeter.greet('world'); // "Hello, world!"
await greeter.visitors(); // 2n
@dfinity/candid is used unmodified, so the argument bytes are the bytes an agent would send.
| Candid | JavaScript |
|---|---|
nat, int, nat64, int64, … | BigInt (2n) — as agent-js users expect |
text | string |
blob / vec nat8 | number[] going in, Uint8Array-convertible coming back (Uint8Array.from(v)) |
opt T | [] or [value] |
variant { a; b } | { a: null } |
principal | Principal from @dfinity/principal |
The return convention is agent-js's own, applied by the library: no return values give undefined, one comes back bare, several come back as a tuple array. Code written against a deployed canister does not have to tell the difference.
Query/update is decided from the factory's annotations, not by guessing from the method name: composite_query and query route through deliverQuery, everything else through deliver.
Blob arguments are ordinary arrays of bytes. From the MoxDB demo, handing a whole wasm module to an actor that will upgrade its rows with it:
const wasm = new Uint8Array(await (await fetch('./moxdb-rowv2.wasm')).arrayBuffer());
log(await db.alterTable([...wasm]));
and the reverse, unpacking a vec nat8 reply — from the knights demo's outcall handling:
const body = new TextDecoder().decode(Uint8Array.from(r.body));
The raw path#
Without an idlFactory an actor still exists; it has callRaw and queryRaw, which take and return bytes. You supply the method's full export name and encode the arguments yourself:
import { IDL } from '@dfinity/candid';
const a = moxzi.actor('greeter'); // no factory: raw only
const reply = await a.callRaw('canister_update greet',
new Uint8Array(IDL.encode([IDL.Text], ['world'])));
const [text] = IDL.decode([IDL.Text], new Uint8Array(reply));
A call with no arguments is the six-byte empty message DIDL\0\0, which is what callRaw and queryRaw send when you omit argBytes.
Hand-declared types work the same way, which is how the demos drive the compiler canister itself — its diagnostics are a real record type, declared once and reused:
const Pos = IDL.Record({ column: IDL.Int, file: IDL.Text, line: IDL.Int });
const Region = IDL.Record({ left: Pos, right: Pos });
const StepResult = IDL.Variant({
more: IDL.Null, needClass: IDL.Text,
done: IDL.Vec(IDL.Nat8), err: IDL.Vec(Diag),
});
const [v] = IDL.decode([StepResult], forgeCompile('step', IDL.encode([IDL.Nat], [job])));
if ('done' in v) { const unlinked = Uint8Array.from(v.done); }
Field names are not on the wire#
A Candid record encodes each field as the 32-bit hash of its label:
h = 0
for each byte b of the UTF-8 label: h = (h * 223 + b) mod 2^32
So a decoder that has no type can tell you the shape of a reply and the value of every field, but the best it can do for a label is print the hash. The devtools panel is exactly this situation — it inspects actors whose bytes never crossed into the page — and its generic decoder falls back to #1224700491-style keys when it has no name for an id.
Its recovery is worth copying if you need untyped decoding: fetch the interface, hash every identifier that appears in it, and look the hashes up.
function candidHash(name) {
let h = 0n;
for (const c of new TextEncoder().encode(name)) h = (h * 223n + BigInt(c)) % 4294967296n;
return h;
}
const iface = await moxzi.candidOf('moxdb'); // the module's candid:service text
const names = new Map();
for (const m of (iface || '').matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
names.set(candidHash(m[0]).toString(), m[0]);
}
candidOf reads the candid:service metadata of the module the runtime is executing, so it works for actors spawned by actors and tracks upgrades. It returns null when the module carries no such section — which is why an untyped decode has to survive missing names rather than assume them.
Two consequences worth stating plainly:
| If you… | Then… |
|---|---|
| Decode with the method's real types | You get field names, because you supplied them |
| Decode with a type whose labels differ | The hashes differ, so the fields you asked for are not the fields that arrived |
| Decode with no type at all | You get #<hash> keys; recover names from candidOf, or accept them |
Principals#
idFromName(name) gives the ten-byte principal the library derives for a named actor. To show or send it, convert with the real library:
import { Principal } from '@dfinity/principal';
Principal.fromUint8Array(idFromName('greeter')).toText();
Things you cannot do#
- Use
@dfinity/agent'sActor. It submits an update, pollsreadState, and verifies a subnet BLS certificate. There is no subnet in a tab, so the only way to satisfy it is to forge one — worse than none, because the verifier would believe it.ic0.data_certificate_presentanswers 0, exactly as it does in an update on the IC. - Rely on the library to encode
canister_initfor you without types.install({ arg, initTypes })encodes withIDL.encode(initTypes, arg); passing values without their types encodes nothing. - Assume a decoded record's key order. The devtools' display sorts keys; the wire does not promise you an order to depend on.
Next#
- /docs/web/api/ —
install,callRaw,candidOfand the rest of the surface. - /docs/web/overview/ — why certificates are absent rather than simulated.
- /docs/web/limits/ — the rest of the negative space.