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 loop-rotation pitfall that can move a correctly written
guard away from the top of the compiled loop, the compiler-generated-loop
pitfall that catches most people off guard the first time, and the
source-level idioms rshooks hooks use to avoid all of it.
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/80_governance:
let mut i = 0u8;
while i < member_count {
guard!(u32::from(SEAT_COUNT)); // maxiter = 20, exact
let this_seat = i;
i = i.wrapping_add(1);
// ... reads the `IS<seat>` hook parameter for this seat ...
}
SEAT_COUNT is 20, a compile-time governance constant, and an earlier
check already rejects any member_count greater than it — so
maxiter = u32::from(SEAT_COUNT) is not just a safe bound, it’s the
exact worst case this loop can ever reach. A smaller value would be wrong
(a chain can configure up to 20 seats); 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; rshooks-testenv
reports it at unit-test time instead, since its own _g enforces the
same cumulative per-id budget. That’s the actual reason $n exists.
wasm-opt block-wrapping and LLVM loop rotation
A guard written correctly at a loop’s top in Rust source can still end up
somewhere other than the very first instruction after the compiled loop
opcode. Distinct compiler behaviors cause this, at different stages of
rshooks build’s pipeline: rshooks build compensates for one of them
automatically; the other needs a source-level idiom.
1. wasm-opt -Oz wraps the loop body in a block
rshooks build runs Binaryen’s wasm-opt -Oz size optimization first, on
each entry’s raw per-entry wasm, before cleaning (optimizer.rs). For a
loop whose body contains an internal early-exit branch — a continue, or
an if/? check on a Result a Hook API call returned — wasm-opt often
restructures it into loop { block { <guard>; .. } }: the break/continue
logic is wrapped in a block so a br/br_if can jump to its end, and
that block lands first inside the loop, ahead of the guard call that
was the loop body’s first statement in source. The guard itself is
untouched — still unconditional, still the first real instruction the
loop runs every iteration — only its position relative to loop moved,
which the checker’s exact loop; i32.const; i32.const; call $_g prologue
match doesn’t tolerate on its own.
rshooks build runs a guard-hoist pass (crates/rshooks-build/src/ guard_hoist.rs) after unnesting and before the guard check specifically to
undo this: it moves a guard prologue found just inside one or more leading
empty blocks back out to sit directly after loop, which is always
semantically identical (the prologue is stack-neutral and no label inside
the blocks targets it). This runs automatically, for every rshooks build/rshooks clean, with no source change required — a hook author
writing an ordinary loop { guard!(N); if !cond { break } body } or while cond { guard!(N); body } never needs to think about this case.
2. LLVM loop rotation moves the guard to the loop’s latch
Separately, at opt-level = 3, LLVM’s own loop-rotation pass can turn
either guard-writing form shown above into a do-while: it duplicates
the loop’s header block (the condition check) into the preheader ahead of
the loop, and moves the original header to the loop’s latch, after the
body, just before the branch back. Which source form stays “guard-first”
after this depends on which block LLVM treats as the header — an LLVM
decision the source doesn’t control:
loop { guard!(N); if !cond { break } body }’s guard is the first statement of the loop body, so it is the header. Rotation duplicates it into the preheader and moves the original pastbody, into the latch — the compiledloopopcode is then followed bybody, not by the guard, and the checker rejects it as missing a guard.while cond { guard!(N); body }’s condition is the header, with the guard as the body’s first statement. Rotation moves the condition to the latch, so the guard ends up leading the rotated loop — this passes.
But rotation only fires when LLVM judges the header “small” (a cheap
condition check); a large or expensive header (many arithmetic/memory
operations) is left un-rotated, and then it’s the while form that fails
instead. Neither fixed source form is guard-first under both outcomes, and
nothing in the source indicates which outcome a given loop will get — the
guard-hoist pass above can’t help here either, since there’s no leading
block to hoist out of: the guard is simply absent from the top of the
compiled loop, moved to its latch.
rshooks::guarded_while!(maxiter, cond, { body }) sidesteps the question
by placing a guard in both positions — the condition block and the top of
the body — so whichever one rotation leaves leading the compiled loop, that
block already starts with a guard call. The cost is one extra _g call per
iteration. See its rustdoc (crates/rshooks/src/macros.rs) for the full
mechanism and the guard-id convention it uses (guard_m! ids 1 and 2
on the macro’s own invocation line). continue inside its body jumps back
to the condition block, which is guarded, so it stays covered too.
Diagnosing which one you’re looking at
rshooks build’s unguarded-loop error names this shape directly when it
recognizes it — a _g call inside the loop’s body that isn’t at its head.
Since the build pipeline’s guard-hoist pass already runs before this check,
a report reaching you from rshooks build is case 2 (rotation): the block
case was already fixed automatically. rshooks check on an already-built
file calls the validator directly, with no hoist pass, so either cause is
still possible there.
To tell them apart by hand:
rshooks build --no-optimizeskipswasm-optentirely. If the same loop now passes, the failure was case 1 (wasm-optblock-wrapping) — LLVM’s own output was already guard-first. If it still fails, it’s case 2 (rotation).wasm-tools print <entry>.wasmon the raw per-entry wasm (see below for how to obtain it) shows the signature directly: a guard id (i32.const <id>) appearing twice — once immediately before theloopopcode (the duplicated preheader copy) and again partway through the loop’s body, at its latch — is rotation (case 2). A singleblockopener immediately afterloop, with the guard as the block’s first instruction, is thewasm-optshape (case 1) — already fixed by the timershooks buildreports anything, so this is only visible with--no-optimizeoff and inspecting an intermediate stage, or by disabling the hoist pass.
The raw per-entry wasm isn’t kept by default; rebuild it directly with the
cargo rustc invocation rshooks build itself uses, e.g.
cargo rustc --cfg rshooks_entry="0" --check-cfg 'cfg(rshooks_entry,values("0","1","2","3","4","5","6","7","8","9"))' --target wasm32v1-none --release -- -C link-arg=-zstack-size=<bytes>
(crates/rshooks-build/src/chain_build.rs’s selected_rustc_args/
cargo_args print the exact, current flags).
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 abcmp-style byte-compare loop. (The protocol newtypes inrshooks::types—AccountId,Hash,Keylet, and the rest — are the exception: theirPartialEqis hand-written to call the matchingbuf_eq_*internally, so==between two of them is already loop-free. This pitfall is specifically about comparing bare[u8; N]arrays.) - Large buffer zero-init or copy — a big stack-local
[0u8; N], or a largememcpy-shaped copy, lowers to amemset/memcpy-style loop.
rshooks build treats an unguarded loop as a hard build error — missing
a guard! in your own code is a bug, not something to silently paper
over — so a compiler-generated loop like this needs a source-level fix,
not a build flag. examples/05_firewall/src/lib.rs compares accounts with
buf_eq_20 explicitly for exactly this reason, and AccountId’s own ==
is itself already loop-free too (see the callout above), since its
PartialEq delegates to buf_eq_20 internally.
The two idioms that avoid it
Two source-level idioms sidestep the compiler-generated loop entirely, and are preferred wherever they apply:
Fixed-size buffer equality — rshooks::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
);
}
firewall’s account comparison calls buf_eq_20 for this reason: it
removes the compiler-generated loop entirely, and the word-at-a-time
comparison keeps its worst-case instruction count well below a derived
array comparison’s. AccountId’s own == delegates to buf_eq_20 as
well (see the callout above), so either spelling is loop-free.
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).
A nested-guarded-loop pitfall: unrolling that duplicates the inner loop
The worst-case-instruction-count model a nested guard! relies on assumes
each guard! call site compiles to exactly one physical loop in the final
module: an inner loop’s cost is meant to be amortized across every
iteration of its outer loop (the outer loop’s own guard! already bounds
how many times that can happen), so the checker only has to charge that
inner loop’s cost once, at the multiplier its maxiter implies relative
to its parent’s.
At opt-level = 3, that assumption can quietly break. LLVM routinely
fully unrolls a small, provably-bounded outer loop (2 or 3 iterations is a
typical threshold) whenever it judges duplicating the body worthwhile —
independent of whether that body is written inline or behind a function
call, since Guard-type hooks force-inline every reachable function into
hook()/cbak() regardless (docs/DESIGN.md §6.2b), so unrolling and
inlining compound. When the outer loop wraps a guard!-protected inner
loop, unrolling physically duplicates that inner loop once per outer
iteration. The checker walks the compiled bytecode, not the source, so
it then counts the inner loop’s full worst-case cost once per duplicate
instead of once total — silently multiplying, not amortizing, its
contribution to the worst-case instruction count.
The actual driver is what happens to the parent loop, not the duplicate
count by itself: the checker’s multiplier for a loop is its own maxiter
divided by whatever its immediate parent’s iteration bound is, so
duplicated copies that stay nested under a real parent loop are still
charged at that parent-divided rate (three copies nested under a
guard!(2) outer loop each cost 66/2, the same total as one copy would)
— no penalty from duplication alone. The penalty shows up specifically
when unrolling removes the parent loop entirely: each duplicate then sits
at the top level, with no parent to divide by, so its multiplier jumps
from 66/2 to the loop’s full, undivided 66/1 — once per duplicate.
examples/80_governance hit this directly: an outer 2-iteration table
loop wrapping a guard!(66)-bounded 32-topic scan got fully unrolled,
doubling the inner loop’s measured cost — worth roughly a third of the
whole govern entry’s worst-case instruction count. rshooks::no_unroll
fixes it by routing the outer loop’s induction variable through
core::hint::black_box at its comparison, which makes the trip count
opaque to the optimizer and keeps the loop as one real loop construct:
use rshooks::{guard, no_unroll};
let mut tbl = 1u8;
while no_unroll(tbl) <= 2 {
guard!(2);
// ... the inner guard!(66)-protected loop goes here, once ...
}
This is the mirror image of the compiler-generated-loop pitfall above:
that section is about the compiler turning no loop into one that needs a
guard; this one is about the compiler turning one guarded loop into
several, silently. Only reach for no_unroll at a call site actually
exhibiting this shape — an outer loop, itself small enough to be a
plausible full-unroll candidate, wrapping further guard!-protected work
— applying it to every guarded loop by default would regress the common
case, where full unrolling is cheaper (straight-line code has no loop
overhead and no worst-case-padding waste). See no_unroll’s own doc
comment (crates/rshooks/src/macros.rs) for the full reasoning, including
its failure mode if a future toolchain ever stopped honoring
black_box’s optimization-barrier hint.
Where to go next
- Anatomy of a Hook covers the
HookStaticidiom 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 the full
build/clean/checkflag reference.