Hello, Internet Computer!

A canister is a WebAssembly binary. Thankfully, the non-standard wasm extensions in early prototypes of the IC were ultimately abandoned, so off-the-shelf tooling just works.

WebAssembly is explained in many other places (plug: see my introduction to WebAssembly), so we only mention that:

  • An IC canister imports various functions from the ic0 module. Instead of calling printf to say something, we might call ic0.msg_reply_data_append and ic0.msg_reply.

  • An IC canister exports functions with names like canister_query foo and canister_update bar. These are called via dfx, or via HTTPS, or by other canisters.

A query is a cheap, read-only call: our code is allowed to modify global variables and memory, but afterwards the changes are reverted. In contrast, the changes made by an update are permanent.

That’s all we need to know to write our first canister, hello.c:

#define IMPORT(m,n) __attribute__((import_module(m))) __attribute__((import_name(n)));
#define EXPORT(n) asm(n) __attribute__((visibility("default")))
void reply_append(void*, unsigned) IMPORT("ic0", "msg_reply_data_append");
void reply       (void)            IMPORT("ic0", "msg_reply");
void go() EXPORT("canister_query hi");
void go() {
  char msg[] = "Hello, World!\n";
  reply_append(msg, sizeof(msg) - 1);
  reply();
}

Build

Turning this into a wasm binary would simply be a matter of running clang --target=wasm32 if it weren’t for some linker options we need:

  • --no-entry: there’s no _start symbol in our file.

  • --export-dynamic: export all our functions.

  • --allow-undefined: avoid complaints about the missing ic0 functions.

We run:

$ clang --target=wasm32 -c -O3 hello.c
$ wasm-ld --no-entry --export-dynamic --allow-undefined hello.o -o hello.wasm

to produce hello.wasm, which we examine with wasm2wat:

$ wasm2wat hello.wasm
(module
  (type (;0;) (func (param i32 i32)))
  (type (;1;) (func))
  (import "ic0" "msg_reply_data_append" (func $reply_append (type 0)))
  (import "ic0" "msg_reply" (func $reply (type 1)))
  (func $canister_query_hi (type 1)
    (local i32)
    global.get 0
    i32.const 16
    i32.sub
    local.tee 0
    global.set 0
    local.get 0
    i32.const 0
    i64.load offset=1031 align=1
    i64.store offset=7 align=1
    local.get 0
    i32.const 0
    i64.load offset=1024 align=1
    i64.store
    local.get 0
    i32.const 14
    call $reply_append
    call $reply
    local.get 0
    i32.const 16
    i32.add
    global.set 0)
  (memory (;0;) 2)
  (global (;0;) (mut i32) (i32.const 66576))
  (export "memory" (memory 0))
  (export "canister_query hi" (func $canister_query_hi))
  (data (;0;) (i32.const 1024) "Hello, World!\0a\00"))

(Not too shabby, but we could save a few bytes by removing the global and the NUL in the string.)

Deploy

We start a local test net and deploy our canister:

$ touch did.not
$ cat > dfx.json << EOF
{"canisters":{"hello":
  {"type":"custom"
  ,"build":""
  ,"candid":"did.not"
  ,"wasm":"hello.wasm"
}}}
EOF
$ dfx start
$ dfx deploy

Above, each entry in the JSON file must have 4 fields:

  • type: we set this to custom to prevent dfx automatically invoking the Motoko or Rust compiler.

  • build: if type is custom, then dfx runs this command to build the canister. We leave it blank so it does nothing; we already built our canister manually.

  • candid: we’ll explain this another time. For now, this must refer to an empty file.

  • wasm: the wasm binary we created.

We test the hi query method:

$ dfx canister call hello hi --output raw
48656c6c6f2c20576f726c6421

The output is dumps our string in hex, which we confirm:

$ dfx canister call hello hi --output raw | xxd -r -p
Hello, World!

To deploy to production, we would add the --network ic option.

TL;DR

We recap the above in the following bash script mkhello.sh, which creates hello.c, compiles it to WebAssembly, deploys it to a local net, and then calls its hi method.

#!/usr/bin/env bash
set -euxo pipefail
cat > hello.c << EOF
#define IMPORT(m,n) __attribute__((import_module(m))) __attribute__((import_name(n)));
#define EXPORT(n) asm(n) __attribute__((visibility("default")))
void reply_append(void*, unsigned) IMPORT("ic0", "msg_reply_data_append");
void reply       (void)            IMPORT("ic0", "msg_reply");
void go() EXPORT("canister_query hi");
void go() {
  char msg[] = "Hello, World!\n";
  reply_append(msg, sizeof(msg) - 1);
  reply();
}
EOF
clang --target=wasm32 -c -O3 hello.c
wasm-ld --no-entry --export-dynamic --allow-undefined hello.o -o hello.wasm
touch did.not
cat > dfx.json << EOF
{"canisters":{"hello":
  {"type":"custom"
  ,"build":""
  ,"candid":"did.not"
  ,"wasm":"hello.wasm"
}}}
EOF
dfx deploy
dfx canister call hello hi --output raw | xxd -r -p

It assumes a local net is already running. To start one up, type the following:

echo {} > dfx.json; dfx start

Then in another terminal, download and run the bash script:

bash <(curl -s https://fxa77-fiaaa-aaaae-aaana-cai.raw.ic0.app/mkhello.sh)

💡