Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Guards and Loops

The Hook host statically rejects any wasm module containing a loop it cannot prove terminates. Every loop — every one, including loops the compiler generates that never appear as a loop keyword in your Rust source — must call the host’s _g guard function at its top, declaring an upper bound on its iteration count. This page covers guard! and guard_m!, the compiler-generated-loop pitfall that catches most people off guard the first time, and the two source-level idioms rshooks hooks use to avoid it entirely.

Why every loop needs a guard

The Hook API’s static guard check exists so a malicious or buggy hook can’t wedge a validator in an infinite (or merely too-expensive) loop during transaction processing. Before a Hook binary can be installed, the host’s guard checker walks every loop in the module and confirms it begins with a call to _g(guard_id, maxiter) — a declaration of “this loop will run at most maxiter times.” At runtime, _g tracks each guard id’s actual iteration count as the hook executes, and the host aborts execution with GUARD_VIOLATION if a loop ever exceeds the maxiter it declared.

rshooks exposes this through two macros that match the C GUARD/ GUARDM macros’ id and iteration-count formulas exactly, so the unsafe call to _g lives inside the macro expansion — hook code never writes unsafe for this.

guard! and guard_m!

guard!(maxiter) goes at the very top of a loop body:

use rshooks::guard;

let mut i = 0;
loop {
    guard!(10);
    if i >= 3 {
        break;
    }
    i += 1;
}
assert_eq!(i, 3);

maxiter is the largest number of times this loop can possibly execute — guard! itself adds + 1 internally to match the C macro’s exact formula, so you supply the true iteration bound, not an off-by-one-adjusted value. Choosing maxiter well means working from a bound you can actually justify from the data’s shape: a fixed array’s length, a documented protocol limit, or a value read from hook_param and validated before use — never “a number that felt safe.” From examples/06_guard-patterns:

fn accounts_equal(a: &AccountId, b: &AccountId) -> bool {
    let mut i: usize = 0;
    loop {
        guard!(ACC_ID_LEN as u32); // maxiter = 20, exact
        if i >= ACC_ID_LEN {
            break true;
        }
        if a.get(i) != b.get(i) {
            break false;
        }
        i = i.wrapping_add(1);
    }
}

ACC_ID_LEN is 20, a compile-time fact about a fixed-size array — so maxiter = 20 is not just a safe bound, it’s the exact worst case. A smaller value would be wrong (this loop really can run 20 times, e.g. two account IDs differing only in their last byte); a larger value would just inflate the hook’s reported worst-case instruction count for no benefit.

guard_m!(maxiter, n) is for the rare case where two textually distinct loops share one physical source line — guard!’s id formula, (1 << 31) + line!(), would otherwise collide for both. The extra n disambiguates them:

let mut i: usize = 0; let mut sum_a: u32 = 0;
loop { guard_m!(8, 1); /* ... */ }
let mut j: usize = 0; let mut sum_b: u32 = 0;
loop { guard_m!(8, 2); /* ... */ }

In real (non-teaching) code this situation arises from generated code — a macro like rshooks::txn_template! that expands to more than one loop at a single call site — rather than from manually cramming code onto one line.

What $n does and doesn’t protect against, verified empirically by examples/06_guard-patterns: giving both loops above the same n (so they collide on one guard id) still passes rshooks build/check without any error — the static checker only verifies loop shape (a guard call at the top of every loop), never that ids are unique across the module. The real hazard is a runtime one: _g tracks each guard id’s iteration count as the hook actually executes, so two unrelated loops sharing an id share one counter — whichever runs first pushes it toward the other loop’s maxiter, risking a spurious on-ledger GUARD_VIOLATION that no build-time tool catches. That’s the actual reason $n exists.

The compiler-generated-loop pitfall

The trap that catches most people writing Rust hooks for the first time: some Rust operations lower to a call into a compiler_builtins function containing a real, unguarded loop, even though no loop appears in your source at all. On wasm32v1-none (the WASM MVP target, with no bulk-memory instructions), this happens for:

  • Fixed-size array/slice equality[u8; N] == [u8; N] lowers to a bcmp-style byte-compare loop.
  • Large buffer zero-init or copy — a big stack-local [0u8; N], or a large memcpy-shaped copy, lowers to a memset/memcpy-style loop.

examples/05_firewall hits exactly this with a sender == blocked account comparison, and as a result needs to be built with:

cargo run -p rshooks-build -- build --manifest-path examples/05_firewall/Cargo.toml \
  --auto-guard --default-maxiter 24

rshooks build defaults to treating an unguarded loop as a hard build error — missing a guard! in your own code is a bug, not something to silently paper over. --auto-guard is the escape hatch for loops the guard checker finds that your source never wrote.

The two idioms that avoid it

Rather than reach for --auto-guard after the fact, two source-level idioms sidestep the compiler-generated loop entirely, and are preferred wherever they apply:

Fixed-size buffer equalityrshooks::buf_eq_8/_20/_32/_33/ _34/_40/_48/_64 compare a buffer as a fixed sequence of word-sized (u64, with a narrower tail word where the size isn’t a multiple of 8) chunks, built from source-level literal byte indices. The comparison is genuinely straight-line code — there is nothing for LLVM to lower into a loop:

if buf_eq_20(&sender, &blocked) {
    rollback!(
        b"guard-patterns: blocked account",
        GuardPatternsError::BlockedAccount
    );
}

Switching firewall’s sender == blocked to buf_eq_20 removed both the loop and the --auto-guard flag entirely, and the word-at-a-time comparison further dropped its worst-case instruction count from 419 to 122.

Statics for templates and large buffers — covered in Anatomy of a Hook: HookStatic moves a template or large buffer into a data segment or BSS instead of runtime store chains or a memset loop, which removes the memcpy/memset-shaped compiler-generated loop the same way buf_eq_* removes the bcmp-shaped one. Applying this idiom to emit-txn removed its only compiler-generated loops entirely and cut its worst-case instruction count by an order of magnitude (6798 → 331, at this toolchain’s opt-level = 3 default — exact numbers drift a little between compiler versions).

When --auto-guard --default-maxiter is the last resort

--auto-guard remains available for cases neither idiom above covers, but treat it as a last resort, not a default habit — it is a real footgun for one specific reason: the CLI only validates guard shape, not that maxiter covers the loop’s true runtime bound. An under-sized --default-maxiter builds clean — the guard checker sees a syntactically valid guard call at the top of the loop and is satisfied — and then fails with GUARD_VIOLATION only later, on a live node, the first time the loop’s actual input pushes it past the value you guessed.

firewall’s own README works through this concretely: --auto-guard’s own default (--default-maxiter 16) would build successfully for its 20-byte account comparison, yet risks a real on-ledger GUARD_VIOLATION, since the compare can run up to 20 iterations — four more than 16 covers. Getting to a safe 24 there meant reasoning about the loop’s true worst-case bound from first principles, not trusting the flag’s default. If you do reach for --auto-guard, size --default-maxiter from the loop’s true worst-case iteration count — found via disassembly, not guessed — every time.

Where to go next

  • Anatomy of a Hook covers the HookStatic idiom in full, including the safety argument for its take-once exclusivity.
  • Accept, Rollback, and Errors covers how a hook actually terminates once its checks — guarded loops included — are done.
  • The rshooks CLI covers --auto-guard and --default-maxiter as build flags in full.