Appearance
Hook Best Practices
Practical guidance for writing correct, installable, and maintainable Hook smart contracts in C. This page splits guidance into two tiers:
- Mandatory rules — things the network or toolchain enforces. Violating one gets your
SetHookrejected outright, or makes the installed hook fail/rollback at runtime. These are not stylistic opinions; they are checked by code before a hook can install or run. - Recommendations — advice that makes hooks more correct, cheaper, and easier to maintain, but that nothing on-ledger checks for. You can ignore these and still get a working, installable hook; you'll just be more likely to ship a bug.
Each point states the rule, why it matters, and — where useful — a short snippet. Cross-links point to the API Reference pages and worked Examples that demonstrate each idea.
Mandatory rules
| Rule | Failure mode |
|---|---|
_g guard first in every loop | Install rejected (GUARD_MISSING) if the loop doesn't open with _g; GUARD_VIOLATION (-16) at runtime if an unguarded/over-iterating path executes anyway |
No user-defined function calls / no call_indirect | SetHook rejected (temMALFORMED) |
memory.grow, memory.copy, memory.fill are disallowed | SetHook rejected (temMALFORMED) |
Block/loop nesting depth ≤ 16 (32 post-fixGuardDepth32) | SetHook rejected (temMALFORMED) |
| Guard-checked worst-case instruction count < 65,535 | SetHook rejected (temMALFORMED) |
| Compiled WASM ≤ 65,535 bytes | SetHook rejected (temMALFORMED) |
| Hook chain ≤ 10 hooks per account | SetHook rejected (temMALFORMED) |
| HookParameter key ≤ 32 bytes, value ≤ 256 bytes | SetHook rejected (temMALFORMED) |
etxn_reserve before any emit, and never emit more than reserved | emit returns PREREQUISITE_NOT_MET (-9) if you never reserved, TOO_MANY_EMITTED_TXN (-13) if you exceed the reserved count |
| State writes respect reserve, size, and modification-count limits | RESERVE_INSUFFICIENT (-38), TOO_BIG (-3), TOO_MANY_STATE_MODIFICATIONS (-44), or TOO_MANY_NAMESPACES (-45) |
| Foreign-account state writes need a grant | NOT_AUTHORIZED (-34), and a subsequent retry in the same hook execution returns PREVIOUS_FAILURE_PREVENTS_RETRY (-35) |
Guard discipline: _g first, in every loop
Every loop must call the guard function _g (via the GUARD /GUARDM macros) at the top of the loop body, before any other work. The SetHook validator statically parses each loop's opening instructions and requires exactly two i32.const pushes followed by a call to the imported _g function; if that pattern is missing it rejects the hook at install time. At runtime, a loop that iterates past the declared maxiter trips GUARD_VIOLATION (-16).
c
for (int i = 0; GUARD(32), i < n && i < 32; ++i) { /* ... */ }GUARD(maxiter)expands to_g((1ULL << 31) + __LINE__, maxiter + 1)— the guard id is derived from the source line, so each loop gets a distinct id.- Use
GUARDM(maxiter, n)when two or more guarded loops share one source line. - A guard's
maxitermust be non-zero, and the loop index must be bounded by a compile-time-visible constant so the checker can compute the worst-case count. - There is a hard cap of 1024 total
_gcalls per hook (MAX_GUARD_CALLS).
The very first statement of hook (and cbak) should be _g(1,1): the body runs at most once, but the guard is still required by convention for symmetry with looped code — see Control APIs.
See Control APIs for _g and macros for the guard macros.
No user-defined functions — inline everything
The guard checker disallows calling any function that is not a whitelisted API import (CALL_ILLEGAL) and also forbids call_indirect (CALL_INDIRECT). In practice this means a hook cannot call its own helper functions or use function pointers: all logic must be inlined into hook/cbak. Either violation is rejected as a temMALFORMED SetHook transaction.
Why: the static guard analysis needs to bound execution, which it cannot do across arbitrary call graphs or indirect calls. This is why the Xahau SDK is built from macros rather than functions — SBUF, BUFFER_EQUAL_GUARD, and the rest expand inline.
How to comply: write reusable logic as macros, or mark helpers inline __attribute__((always_inline)) so the compiler eliminates the call. Verify with mise run build-test-hook (or the project's hook build) that the resulting WASM installs — a stray real call will be rejected at SetHook.
No memory.grow, memory.copy, memory.fill
The guard checker unconditionally rejects memory.grow (MEMORY_GROW), and (since the fix20250131 guard-rules revision) also rejects the bulk-memory memory.copy and memory.fill instructions. A hook's linear memory is fixed at compile time — don't rely on growing it, and don't rely on the compiler lowering memcpy/memset to these opcodes for large or non-constant-sized operations (small, constant-sized copies are typically lowered to plain load/store instructions and are unaffected).
Why: unbounded memory growth and bulk-memory operations undermine the same static-bound guarantees that guarded loops exist to provide, so the validator closes them off entirely rather than trying to bound them.
Bound nesting depth
The guard checker enforces a maximum block/loop nesting depth — 16 levels by default, 32 under the fixGuardDepth32 guard-rules revision (NESTING_LIMIT). Exceeding it rejects the hook at install time.
Why: the worst-case-execution calculation walks the block tree recursively: without a depth cap, deeply nested control flow could make that computation itself unbounded.
Keep hooks small and under the instruction cap
Two independent, enforced limits apply:
- WASM binary size ≤ 65,535 bytes (
0xFFFF). The compiled blob is checked againsthook::maxHookWasmSize()and rejected (temMALFORMED, logged asWASM_TOO_BIG) if larger — both forhsoCREATEand for a hook update that replaces the code. - Guard-computed worst-case instruction count < 65,535. The guard checker walks every block, multiplies loop bodies by their declared
maxiter, and sums the result; if that worst-case count reaches0xFFFFit logsINSTRUCTION_EXCESSand rejects the install. This is why a hook with modest source size can still fail to install if its guarded loops have largemaxitervalues.
Because installability is a hard ceiling, not just a cost consideration, prefer smaller hooks that return as soon as they know the answer:
c
if (otxn_type() != ttPAYMENT)
accept(SBUF("not a payment"), 0); // skip the rest for non-PaymentsGate on the cheap checks (otxn_type, a parameter presence test) before doing expensive slot or state work — this keeps you under both limits and reduces the per-instruction execution fee charged for the hook's actual instruction count (hook::computeExecutionFee).
Other install-time limits
A few more structural limits are enforced at install time, all resulting in temMALFORMED:
- Hook chain length ≤ 10 (
hook::maxHookChainLength()) — an account cannot install an 11th hook. - HookParameter key ≤ 32 bytes, value ≤ 256 bytes (
hook::maxHookParameterKeySize()/maxHookParameterValueSize()). hsoNSDELETErequires exactly thehsfNSDELETEflag; overriding or deleting an existing hook requireshsfOVERRIDE— sending the wrong flag combination for the operation is rejected before the WASM is even inspected.
Emission caveats: reserve first, fee via etxn_fee_base
To emit a transaction: (1) etxn_reserve(n) up front for the exact count, (2) build the template and embed EmitDetails with etxn_details, (3) compute the fee with etxn_fee_base after the details are in place, (4) emit.
- Enforced:
emit()returnsPREREQUISITE_NOT_MET (-9)ifetxn_reservewas never called, orTOO_MANY_EMITTED_TXN (-13)if you emit more than you reserved.etxn_reserveitself can only be called once per hook execution (ALREADY_SET (-8)on a second call) and is capped athook_api::max_emit(255) reservations. - The fee must be derived, not hardcoded — it depends on the exact template size, which includes the
EmitDetailsblock. ThePREPARE_TXN()macro generated by the Transaction Builder orders these steps correctly. - Emitted transactions apply after the current one; observe their outcome in
cbak, not inline. Emit chains increase generation and burden (etxn_generation/etxn_burden). emitreturns32(writing the txn hash) on success;EMISSION_FAILURE (-11)means the ledger rejected the transaction body itself (e.g. malformed STTx, disallowed pseudo-transaction, or a type not permitted byhookCanEmit).
See Emit APIs and the emitted-transaction example.
Mind the reserve and state limits
State writes are bounded by several runtime-enforced limits, checked when the hook writes state:
- Each new state entry consumes owner reserve on the hook account; a write that would exceed the account's available reserve returns
RESERVE_INSUFFICIENT (-38). - State values are up to 256 bytes by default (up to
256 × hookStateScale, max 4096 athookStateScale = 16); a larger write returnsTOO_BIG (-3)(hook::maxHookStateDataSize). - There is a per-hook-execution cap of 256 state modifications (
hook::max_state_modifications); exceeding it returnsTOO_MANY_STATE_MODIFICATIONS (-44). - An account may have at most 256 namespaces (
hook::maxNamespaces()); creating one more returnsTOO_MANY_NAMESPACES (-45).
Why: state that "sometimes fails to save" is usually one of these enforced limits, not a logic bug — check the state_set return rather than assuming it stored.
To delete an entry (and reclaim its reserve), write a zero-length value: state_set(0, 0, SBUF(key)).
Foreign state: reads are open, writes need a grant
state_foreign reads any account's namespace without permission. state_foreign_set to another account only succeeds if that account published a matching grant (sfHookGrants naming your hook's sfHookHash, and optionally an sfAuthorize account); otherwise it returns NOT_AUTHORIZED (-34).
Writing to the hook's own account never needs a grant. Once a foreign write has failed for lack of a grant, the failure latches for the rest of the hook's execution: a retry returns PREVIOUS_FAILURE_PREVENTS_RETRY (-35) without re-checking grants.
See State APIs and the foreign-state example.
Recommendations
The rest of this guidance improves correctness, cost, and maintainability, but nothing in the toolchain or runtime enforces it — a hook that ignores it can still install and run.
Check every API return value
Every Hook API function returns int64_t, and a negative value is an error code (see the full table in Overview). Non-negative returns carry a result: bytes written, a slot number, an XFL value, or 32 for an emitted-transaction hash. Never use a returned value without checking its sign first — a length you assume is positive may be DOESNT_EXIST (-5) or OUT_OF_BOUNDS (-1).
c
int64_t len = otxn_field(SBUF(buf), sfAmount);
if (len < 0)
rollback(SBUF("read failed"), 1); // handle before using bufWhy: state reads that miss, fields that are absent, and buffers that are too small all return quietly as negative numbers. Treating them as data corrupts later logic. The project's test hooks assert on exact return values for exactly this reason.
Manage buffer sizes explicitly
Serialized values have exact sizes; size buffers to them and check the returned length against what you expect.
| Value | Size | Notes |
|---|---|---|
| AccountID (raw) | 20 bytes | otxn_field(sfAccount/sfDestination) returns 20. |
| Hash / state key / namespace | 32 bytes | State keys are up to 32 bytes; namespaces are exactly 32. |
| Native (XAH) amount | 8 bytes | otxn_field(sfAmount) returns 8; convert with AMOUNT_TO_DROPS. |
| IOU amount | 48 bytes | otxn_field(sfAmount) returns 48 (value only, no field code). |
| Emitted-txn hash | 32 bytes | emit returns 32 on success. |
Why: a buffer sized at 8 for sfAmount truncates an IOU (or returns TOO_SMALL (-4)); the 8-vs-48 length is itself a reliable native/IOU discriminator. The payment-filter example relies on exactly this.
c
uint8_t amt[48]; // fits XAH (8) or IOU (48)
int64_t n = otxn_field(SBUF(amt), sfAmount);
if (n == 8) { /* native */ }
else if (n == 48) { /* IOU */ }Avoid state-key collisions
State lives under (account, namespace, key). Within one namespace, keep logical maps apart with a key prefix or use separate namespaces.
c
uint8_t key[21];
key[0] = 0x01; // map discriminator
otxn_field(key + 1, 20, sfAccount); // per-account key: 0x01 + AccountIDWhy: two features that both key state by AccountID will overwrite each other if they share the exact same key. A one-byte prefix (keys are up to 32 bytes, an AccountID is 20) or a distinct namespace keeps them separate. See State APIs and the state-counter example.
Use meaningful accept / rollback messages and codes
The message and error code you pass to accept / rollback are recorded in the transaction metadata: each hook execution produces an sfHookExecution object carrying sfHookResult (accept vs rollback), sfHookReturnCode (your code), and sfHookReturnString (your message). Use distinct codes per failure site.
c
if (BUFFER_EQUAL_20(dest, BLOCKED))
rollback(SBUF("destination blocked"), 2); // code 2 is unique to this pathWhy: when a transaction is rejected on-ledger, these fields are the only record of why. Reusing one code (or an empty message) everywhere makes production failures undiagnosable. The DONE, DONEMSG, NOPE, and ASSERT macros in macros build well-formed terminations (they use __LINE__ as the code, which is naturally unique per site).
Handle Amounts and serialized objects correctly
- Do not byte-compare amounts. Read them as XFL —
otxn_slot→slot_subfield(sfAmount)→slot_float, orfloat_sto_seton a serialized amount — and compare withfloat_compare(which returns1/0, or a negative error, not a subtraction sign). AMOUNT_TO_DROPSonly works on an 8-byte native amount buffer; it returns-2when the amount is not native. Treat any negative as "not XAH".- Locate STObject subfields with
sto_subfield/sto_subarray, which return a packed offset+length: unpack withSUB_OFFSET(x)andSUB_LENGTH(x).
See Float & Amount APIs, utility, and macros.
Don't ship heavy trace calls in production
trace, trace_num, and trace_float write to the server's trace-level log (tagged HookTrace[<account>]:); they produce no on-ledger output and are no-ops when trace logging is disabled. The TRACEVAR / TRACEHEX / TRACEXFL / TRACESTR macros are additionally gated on the DEBUG flag and compile out under NDEBUG.
Why: even a no-op trace call is still code — it adds to the WASM size and the instruction budget. Use the DEBUG-gated macros so release builds drop them, and avoid leaving verbose tracing in a hook you intend to install on mainnet. See the debugging section of the memo-routing example.
Distinguish "not found" from "error"
Several APIs signal absence with a specific code you should branch on separately from real failures: state/state_foreign return DOESNT_EXIST (-5) for a missing key, otxn_param returns DOESNT_EXIST (-5) for an absent parameter, slot_subfield(sfMemos) returns DOESNT_EXIST (-5) when there are no memos, and slot_count returns NOT_AN_ARRAY (-22) on a non-array.
Why: "the value isn't set yet" is usually normal control flow (use a default, start a counter at zero), whereas OUT_OF_BOUNDS or TOO_BIG is a bug. Collapsing them into one < 0 branch hides real errors and mishandles first-run cases — see the State Counter and Foreign State examples.
Structure hooks for testability
A hook can be tested by embedding it as C source, compiling it, installing it with SetHook, then exercising it by sending a transaction and asserting on the result and the emitted metadata — the pattern this project's own test suite uses.
- Keep decision points as explicit
rollback(msg, code)calls with unique codes, so a test can assert on the exact rejection reason. - Prefer
otxn_param/ HookParameters for inputs a test can vary, over hard-coded constants. - Build with
mise run build-test-hookand run the suite withmise testto confirm the hook both installs (passes guard/validation) and behaves.
Why: a hook that only "looks right" but fails SetHook validation, or whose rejection reasons are indistinguishable, is hard to trust. The test harness is the fastest way to prove both the static (installable) and dynamic (correct) properties.
Related documents
- Home
- Overview: The Xahau Hook Model
- Glossary
- Helper Macros (
hook/macro.h) - Transaction Builder
- Control APIs
- Transaction APIs
- State APIs
- Slot APIs
- Ledger APIs
- Emit and Emitted-Transaction APIs
- Float and Amount APIs
- Utility APIs
- Example: Payment Filter
- Example: State Counter
- Example: Emitting a Transaction
- Example: Foreign State
- Example: Memo Routing