Skip to content

Compiling Hooks ​

A Hook is not C source — it is the compiled WebAssembly (WASM) module stored in sfCreateCode. Getting from a .c file to an installed hook means passing through a compiler, a cleaning step, and finally a static validator that runs inside xahaud itself before the SetHook transaction is ever queued. This page describes exactly what that validator checks — the module shape it demands, the toolchain that produces a conforming binary, and the concrete rejection reasons you'll hit if it doesn't.

What compilation produces ​

The compiler output is a single WASM module. xahaud never executes anything else: no interpreted source, no bytecode format of its own, just the standard WASM binary format loaded into a WasmEdge VM instance per execution. Overview's "Compilation pipeline" section covers the toolchain names in brief; this page covers what the validator actually enforces on the resulting binary.

Every hook must also declare which Hook API ABI it targets via HookApiVersion. On this branch the field is required on hsoCREATE and the only accepted value is 0 — the guard-type ABI this page describes. Any other value, or an absent field, is rejected as temMALFORMED (API_MISSING / API_INVALID) before the WASM itself is even inspected.

Because the whole point of the guard system is to let every node compute an identical worst-case instruction bound for a hook before running it, the validator's checks below aren't style preferences — they're what make a guard-type hook's execution boundable and deterministic across the network in the first place.

The required module shape ​

SetHook runs the compiled blob through a static, single-pass WASM validator — followed by a real WasmEdge load-and-validate smoke test, entirely during preflight (before any ledger state is touched, and identically on every node). Both checks are mandatory and independent; either failure makes the whole SetHook transaction temMALFORMED.

Entry points: hook and cbak ​

A conforming module exports exactly:

c
int64_t hook(uint32_t reserved);          // required
int64_t cbak(uint32_t reserved);          // optional — only if the hook emits

The validator parses the export section looking specifically for entries named hook and cbak. hook is mandatory (EXPORT_MISSING if absent); cbak is optional. For whichever of the two is present, the validator resolves it to a function type and checks that type has exactly one parameter of type i32 (PARAM_HOOK_CBAK otherwise) and exactly one result of type i64 (RETURN_HOOK_CBAK otherwise). If both hook and cbak are exported, they must resolve to the same function type (HOOK_CBAK_DIFF_TYPES otherwise) — so cbak's signature can't drift from hook's even if you never touch it directly.

There is no separate "does hook return the right thing" check beyond the type signature — a hook never actually returns a value to the ledger in the normal sense, since it's expected to terminate via accept()/rollback(). See Overview: Entry points for that convention.

Extra exports vs. custom sections ​

A raw clang-targeting-wasm32 build typically emits more than just hook and cbak — helper exports, and (if debug info is on) custom sections. These are treated very differently:

  • Extra exports are silently skipped, not rejected. The export-section loop only special-cases entries literally named hook or cbak; any other export name is walked past without inspection.
  • A custom section (WASM section id 0) is a hard reject. This is the actual reason an unprocessed clang/emscripten build usually fails to install — debug-info and name-mapping data land in custom sections, and the validator rejects the presence of any custom section outright, independent of what it contains.

So "hook-cleaner strips extra exports" is a cleanliness step, not something the validator requires — but "hook-cleaner strips custom sections" is mandatory, because the validator won't accept a binary that still has one.

Imports: only the Hook API, nothing else ​

Every function a hook imports must come from the env module and must be a name present in the current Hook API whitelist (amendment-gated per function). Importing from any other module name, or importing a name the whitelist doesn't recognize, is rejected (IMPORT_MODULE_ENV, IMPORT_ILLEGAL). This whitelist is also where the mandatory _g guard import is discovered.

A subtlety worth knowing: every hook must import _g, even one with no loops at all. After the import section is fully parsed, the validator unconditionally checks that _g was among the imports and rejects the whole transaction (GUARD_IMPORT) if not — this check doesn't depend on whether the hook contains any guarded loops. In practice this means every hook must call _g at least once somewhere in its source (conventionally _g(1,1) as the first statement of hook), or the compiler won't emit the _g import and the binary will never pass validation.

No user-defined function calls ​

The guard checker disallows calling any function whose index is beyond the imported functions — i.e., a locally-defined function in the module's own code section can never legally be the target of a call instruction (CALL_ILLEGAL). call_indirect (function-pointer/vtable calls) is rejected outright and unconditionally (CALL_INDIRECT).

Note what this means precisely: the mechanism is a call-graph restriction, not an export whitelist and not a "no local functions" ban at the WASM level. A locally-defined helper function can exist in the compiled module's code section — it's just that no instruction in the module is permitted to call it, so it can never execute. In practice, always compile with helpers marked inline __attribute__((always_inline)) (or written as macros) so the compiler eliminates the call entirely, rather than relying on the validator to reject a stray real call after the fact. See Best Practices: No user-defined functions — inline everything.

memory.grow is rejected unconditionally (MEMORY_GROW); the bulk-memory memory.copy and memory.fill instructions are additionally rejected once the fix20250131 guard-rules revision is enabled (GuardRuleFix20250131). Small, constant-sized memcpy/memset calls are typically lowered by the compiler to plain load/store instructions and are unaffected.

Guard validation at install time ​

Every loop instruction (WASM opcode 0x03) must open with the exact byte pattern i32.const <guard_id>, i32.const <maxiter>, call <_g's import index> — the validator parses these three instructions literally at the top of each loop body. maxiter must be a non-zero literal (i32.const, not a computed value), and the called function index must resolve to the imported _g. Missing or malformed pieces of this pattern reject with GUARD_MISSING; a maxiter of zero is rejected the same way.

This is the install-time check the GUARD/GUARDM macros exist to satisfy mechanically, and it's what _g enforces at runtime once a loop is inside its budget. This page doesn't duplicate that semantics — see those two pages for the runtime guard model and the macro forms.

Two more structural limits are enforced in the same pass, both walking the block/loop/if nesting tree the guard scan builds:

  • Block/loop/if nesting depth is capped at 16 levels (32 under the fixGuardDepth32 guard-rules revision); exceeding it rejects with NESTING_LIMIT.
  • Worst-case instruction count, computed by multiplying each loop's instruction count by its declared maxiter recursively up the block tree, must stay under 65,535; reaching or exceeding it rejects with INSTRUCTION_EXCESS.

There is also a hard cap of 1024 total _g calls per hook (MAX_GUARD_CALLS), checked both at loop-start guard calls and any other call to _g.

Structural limits: size and the smoke test ​

Independently of guard validation, the compiled blob itself is size-checked against a fixed ceiling (65,535 bytes / 0xFFFF) — this check runs in preflight for every sfHook entry that carries sfCreateCode, before guard validation even starts, and rejects temMALFORMED (WASM_TOO_BIG) if exceeded.

After guard validation passes, xahaud independently loads the module into a real WasmEdge VM instance and asks WasmEdge itself to load and validate the module — a smoke test that catches malformed WASM the static guard scan doesn't reject but that genuinely isn't loadable or instantiable. Failure here is logged as WASM_TEST_FAILURE and also produces temMALFORMED.

Guard validation is also re-run (with ledger access this time, though the routine itself stays context-free) at apply time for a genuinely new hash, and its failure there maps to tecINTERNAL rather than temMALFORMED — see CreateCode for that apply-time detail.

Toolchain pipeline ​

The recommended way to produce a conforming binary from C source is hooks-compiler: one npx command that runs clang, wasm-opt, and hook-cleaner as WebAssembly inside Node, needs no toolchain install, and produces byte-identical output for the same source.

sh
npx hooks-compiler build hook.c -I include -o hook.wasm -Oz

Its output is already the cleaned module, ready for hook_validate (see Validating Hooks) and sfCreateCode:

hook.c  →  hooks-compiler  →  hook.wasm (clean)  →  hook_validate
                                      │
                         hex-encode into sfCreateCode
                                      │
                              SetHook transaction

The underlying tools ​

hooks-compiler packages the tools the Hooks ecosystem has always used; knowing them explains what the one command does, and they remain the way to build when you are not compiling from C (see Developing Hooks in Languages Other Than C):

  1. wasmcc (from wasienv) — a clang-based compiler that targets wasm32, producing the raw WASM module from C source. hooks-compiler runs clang and wasm-ld directly for this step.
  2. hook-cleaner (from hook-cleaner-c) — strips custom sections and non-hook/cbak exports from the raw compiler output. The custom-section strip is what actually makes the difference between "installs" and CUSTOM_SECTION_DISALLOWED; the export strip is cosmetic (see above), but keeps the binary minimal and its behavior easy to reason about.
  3. wat2wasm (from wabt) — only needed if you're hand-writing or inspecting WebAssembly Text (.wat) rather than compiling from C.

Run by hand, the pipeline is:

hook.c  →  wasmcc  →  hook.wasm (raw)  →  hook-cleaner  →  hook.wasm (clean)

The reference hooks used throughout these docs are compiled from C source embedded in xahaud's own test suite, via a repeatable local build task.

The final step — hex-encoding the cleaned WASM bytes into sfCreateCode — is mechanical; see CreateCode for the field itself and sethook-fields/README for the rest of a SetHook entry's required fields (HookNamespace, HookApiVersion, HookOn).

The edit → compile → install → test loop ​

A practical development cycle:

  1. Edit the C source, following the mandatory rules in Best Practices: Mandatory rules — guard discipline, inlined helpers only, size/instruction budgets.
  2. Compile with hooks-compiler, which also cleans the module (or run wasmcc then hook-cleaner by hand).
  3. Validate the module with the hook_validate RPC on a node whose amendments match your target network. It runs the same checks as SetHook preflight and reports the worst-case instruction count, so a module that fails never has to cost you a transaction.
  4. Install with a SetHook transaction — hsoCREATE for new code, or hsoUPDATE/hsoINSTALL for an existing hash. See sethook-fields/README for the full operation-inference rules.
  5. Test by sending transactions that should trigger the hook and inspecting sfHookExecutions metadata, or by tracing with trace/trace_num/trace_float (see the debugging guidance in Best Practices: Don't ship heavy trace calls in production).
  6. On a rejection, read the log code (see the table below) and go back to step 1. hook_validate reports it in its log array as HookSet(<number>); a rejected SetHook transaction records the same code.

Common install-rejection causes ​

The number is the code's numeric value, which is how validator log lines print it (HookSet(28) is GUARD_MISSING); the hook_validate RPC returns those lines in its log array.

Symptom (log code)CodeCause
WASM_TOO_BIG79Compiled (and cleaned) blob exceeds 65,535 bytes.
CUSTOM_SECTION_DISALLOWED86Raw compiler output still carries a debug/name custom section — run it through hook-cleaner.
EXPORT_MISSING / EXPORT_HOOK_FUNC15 / 14No export named hook at all, or it doesn't have type int64_t hook(uint32_t).
EXPORT_CBAK_FUNC / HOOK_CBAK_DIFF_TYPES13 / 82cbak is exported with the wrong signature, or a different type than hook.
PARAM_HOOK_CBAK / RETURN_HOOK_CBAK70 / 71hook/cbak doesn't take exactly one i32 parameter and return exactly one i64.
IMPORT_MODULE_ENV / IMPORT_ILLEGAL46 / 44An import isn't from module env, or its name isn't a whitelisted Hook API function.
GUARD_IMPORT27_g is never called anywhere in the hook, so the compiler never emitted its import.
GUARD_MISSING28A loop doesn't open with the literal i32.const, i32.const, call _g pattern, or maxiter is 0.
CALL_ILLEGAL6The module calls a locally-defined (non-inlined) helper function instead of a Hook API import.
CALL_INDIRECT7The module uses a function pointer / call_indirect.
MEMORY_GROW52The module uses memory.grow.
NESTING_LIMIT84Block/loop/if nesting exceeds 16 levels (32 post-fixGuardDepth32).
INSTRUCTION_EXCESS51Guard-computed worst-case instruction count reaches 65,535.
WASM_TEST_FAILURE78Guard validation passed but WasmEdge couldn't load/validate the module (genuinely malformed WASM).
API_MISSING / API_INVALID4 / 3sfHookApiVersion absent, or not 0, on hsoCREATE.