moxzi
Docs / On-chain builds / Linking and installing

Linking and installingalpha

Why is the artifact unlinked, and what do I do with it?

The wasm the compiler canister produces is an unlinked module: the Motoko runtime is not in it, its RTS imports are unresolved, and link-only exports are still present. It is not a deployable canister and it is not supposed to be. Linking is a second, separate step, and moxzi build --remote performs it for you locally before writing the file.

The artifact fails validation, and that is correct#

If you fetch bytes straight out of getArtifact, getArtifactChunk or jobWasmChunk and run a wasm validator over them, validation fails. This is the single most common confusion about on-chain builds, so it is worth being blunt:

observationverdict
wasm-validate rejects the artifactexpected — it is unlinked
the module has a duplicate keep_memory_reserve exportexpected — link-only exports are pruned by the linker
the module imports symbols nothing providesexpected — the RTS supplies them
dfx canister install rejects itexpected — link it first
getCandid(id) returns service : { }expected on the EOP backend — see below

None of these are bugs. The compiler emits -no-link output by design, exactly as moc -c does, and the linker is a different program.

What the linker actually does#

The linker embeds the enhanced-orthogonal-persistence RTS, resolves relocations, prunes the link-only exports, and re-attaches the custom sections that carry the candid interface and the persistence marker.

That last clause is load-bearing. The wasm AST the linker decodes into carries no custom sections, so a naive decode-link-encode drops all of them. When that happened, it silently removed icp:private enhanced-orthogonal-persistence and the IC then refused every upgrade:

Invalid upgrade option: The `wasm_memory_persistence: opt Keep` upgrade option
requires that the new canister module supports enhanced orthogonal persistence.

About 50 stabilization and upgrade tests failed and it looked exactly like a compiler regression. The linker now copies the input's custom sections through verbatim — except name, whose function indices are stale once the RTS adds functions, which is what moc's linker does too.

The linker is itself a Motoko canister, and it is the same one the on-chain pipeline runs. moxzi hosts that wasm in-process under wasmtime rather than reimplementing the linker in Rust, so a locally linked artifact and an on-chain linked one come out of identical code.

Linking by hand#

moxzi link hello-unlinked.wasm -o hello.wasm

Default output is <input>.linked.wasm. moxzi link writes no .did.

The linker wasm is found in this order: --linker <path>, then $MOXZI_LINKER, then <exe dir>/linker.wasm, then <exe dir>/../lib/moxzi/linker.wasm, then bin/linker-canister/linker.wasm. If none exist, build it:

make moxzi-linker      # -> bin/linker-canister/linker.wasm

Under 2 MiB the CLI calls the linker's one-shot link(blob). Above that it uses clearChunksaddChunk (1 MiB at a time) → linkChunksgetOut(offset, len), because a multi-megabyte module exceeds the ingress caps when the linker is a real canister. The same code path is used locally so there is only one path to keep honest.

Linking on-chain instead#

Deploy the linker as its own canister and drive the chunked API against it — that is what test/pic-js/scripts/link-only.mjs does, and link-install-prove.mjs goes on to install the result and prove it answers. The linker canister needs a large wasmMemoryLimit (the pic-js drivers give it 12 GiB; the deployment runbook says over 3 GiB for large modules).

This matters for the provenance argument. moxzi build --remote links locally, and that is a deliberate limitation rather than an oversight: linking is a mechanical, deterministic step over the compiler's output, so it does not weaken the claim the way a local compile would. Anyone who wants the whole pipeline attested will want the link moved on-chain too, and that is separate work.

The .did gotcha#

On the EOP backend the compiler sets the job's candid text to the placeholder service : { } — the interface file is a separate artifact that does not affect the wasm bytes. The real interface lives in the linked module's candid:service custom section.

buildwhere the .did comes from
local moxzi buildthe linked wasm's candid:service section, falling back to getCandid
moxzi build --remotegetCandid only

So a remote build can write a .did containing service : { }. If that happens, the interface is still in the linked wasm; extract it from the candid:service custom section rather than trusting the written file. To get the section emitted as public metadata at all, the build must pass publicMetadatadfx-shaped frontends pass ["candid:service", "candid:args"], and that is what make compiler-canister-wasm does for the compiler itself.

Installing#

Once linked, the artifact is an ordinary canister module: dfx canister install, ic-admin, an agent, or the management canister directly. Nothing about it is special.

The compiler canister also offers installFresh(id, args), which creates a canister and installs the job's artifact into it. Read the constraints before reaching for it:

constraintdetail
operator-only, and you must own the jobit spends 1 T cycles of the canister's own balance, so unauthenticated it would be a direct drain
the caller becomes the sole controllerearlier it left the compiler as sole controller, so the deployer could not upgrade their own canister
mode is #installfresh install, not upgrade
it installs job.wasmwhich is the unlinked module — this helper is for the operator-driven pipeline, not a substitute for linking

For a normal build, link first and install with your usual tooling.

Things you cannot do#

Why
Deploy getArtifact output directlyIt is unlinked. Validation and install both refuse.
Ask the compiler canister to linkThere is no link method on it. Linking is a separate canister and a separate binary throughout.
Skip the linker for "simple" programsThe RTS is not optional; hello-world needs it too.
Keep the name custom section through a linkIts function indices are stale once the RTS adds functions. moc drops it as well.
Upgrade a canister built by a linker that drops custom sectionsThe IC checks for the persistence marker and refuses. If upgrades start failing, suspect the linker, not the compiler.

Next#

On this pageThe artifact fails validation, and that is correctWhat the linker actually doesLinking by handLinking on-chain insteadThe .did gotchaInstallingThings you cannot doNext