Appearance
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 emitsThe 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
hookorcbak; 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 unprocessedclang/emscriptenbuild 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
fixGuardDepth32guard-rules revision); exceeding it rejects withNESTING_LIMIT. - Worst-case instruction count, computed by multiplying each loop's instruction count by its declared
maxiterrecursively up the block tree, must stay under 65,535; reaching or exceeding it rejects withINSTRUCTION_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 -OzIts 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 transactionThe 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):
wasmcc(fromwasienv) — a clang-based compiler that targetswasm32, producing the raw WASM module from C source. hooks-compiler runs clang andwasm-lddirectly for this step.hook-cleaner(fromhook-cleaner-c) — strips custom sections and non-hook/cbakexports from the raw compiler output. The custom-section strip is what actually makes the difference between "installs" andCUSTOM_SECTION_DISALLOWED; the export strip is cosmetic (see above), but keeps the binary minimal and its behavior easy to reason about.wat2wasm(fromwabt) — 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:
- Edit the C source, following the mandatory rules in Best Practices: Mandatory rules — guard discipline, inlined helpers only, size/instruction budgets.
- Compile with hooks-compiler, which also cleans the module (or run
wasmccthenhook-cleanerby hand). - Validate the module with the
hook_validateRPC on a node whose amendments match your target network. It runs the same checks asSetHookpreflight and reports the worst-case instruction count, so a module that fails never has to cost you a transaction. - Install with a
SetHooktransaction —hsoCREATEfor new code, orhsoUPDATE/hsoINSTALLfor an existing hash. See sethook-fields/README for the full operation-inference rules. - Test by sending transactions that should trigger the hook and inspecting
sfHookExecutionsmetadata, or by tracing withtrace/trace_num/trace_float(see the debugging guidance in Best Practices: Don't ship heavy trace calls in production). - On a rejection, read the log code (see the table below) and go back to step 1.
hook_validatereports it in itslogarray asHookSet(<number>); a rejectedSetHooktransaction 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) | Code | Cause |
|---|---|---|
WASM_TOO_BIG | 79 | Compiled (and cleaned) blob exceeds 65,535 bytes. |
CUSTOM_SECTION_DISALLOWED | 86 | Raw compiler output still carries a debug/name custom section — run it through hook-cleaner. |
EXPORT_MISSING / EXPORT_HOOK_FUNC | 15 / 14 | No export named hook at all, or it doesn't have type int64_t hook(uint32_t). |
EXPORT_CBAK_FUNC / HOOK_CBAK_DIFF_TYPES | 13 / 82 | cbak is exported with the wrong signature, or a different type than hook. |
PARAM_HOOK_CBAK / RETURN_HOOK_CBAK | 70 / 71 | hook/cbak doesn't take exactly one i32 parameter and return exactly one i64. |
IMPORT_MODULE_ENV / IMPORT_ILLEGAL | 46 / 44 | An import isn't from module env, or its name isn't a whitelisted Hook API function. |
GUARD_IMPORT | 27 | _g is never called anywhere in the hook, so the compiler never emitted its import. |
GUARD_MISSING | 28 | A loop doesn't open with the literal i32.const, i32.const, call _g pattern, or maxiter is 0. |
CALL_ILLEGAL | 6 | The module calls a locally-defined (non-inlined) helper function instead of a Hook API import. |
CALL_INDIRECT | 7 | The module uses a function pointer / call_indirect. |
MEMORY_GROW | 52 | The module uses memory.grow. |
NESTING_LIMIT | 84 | Block/loop/if nesting exceeds 16 levels (32 post-fixGuardDepth32). |
INSTRUCTION_EXCESS | 51 | Guard-computed worst-case instruction count reaches 65,535. |
WASM_TEST_FAILURE | 78 | Guard validation passed but WasmEdge couldn't load/validate the module (genuinely malformed WASM). |
API_MISSING / API_INVALID | 4 / 3 | sfHookApiVersion absent, or not 0, on hsoCREATE. |