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

Introduction

rshooks is a Rust toolchain for writing Xahau Hooks — small WebAssembly programs that attach to an account and run alongside transactions that touch it. It covers the whole path from source to a deployable binary: an ergonomic Rust API for the Hook host functions, procedural macros that remove the WASM export boilerplate, and a build CLI that turns a cargo build artifact into a SetHook-valid .wasm file.

What is a Xahau Hook?

A Hook is a tiny WebAssembly module installed on an account via a SetHook transaction. Once installed, it runs synchronously whenever a transaction matching its trigger set (HookOn) touches that account — before the transaction is finally applied, for incoming or outgoing transactions or both. The Hook inspects the transaction and the ledger through a fixed set of host-provided API functions, then either accepts (lets the transaction proceed) or rolls back (rejects it), optionally emitting new transactions of its own along the way.

Because a Hook runs as part of consensus, the host imposes strict rules a compiled binary must satisfy: it may export only hook (and optionally a cbak settlement callback), every loop must carry an explicit guard so the host can statically bound its worst-case execution, the instruction set is plain WebAssembly 1.0 with no floating-point opcodes at all, the call graph must be acyclic, and the whole module must fit in 65,535 bytes. These constraints are why Hooks aren’t written like ordinary Rust programs — rshooks exists to meet them without making the developer hand-encode WASM exports or floating-point-free arithmetic by hand.

The five crates

rshooks is a small monorepo, layered so each crate has one job:

cratedescription
rshooks-coreno_std, zero-logic FFI layer: raw Hook API declarations and every constant from the xahaud hook/ headers, translated 1:1 into Rust.
rshooks-macrosProcedural macros for rshooks (the #[hooks] struct/impl attribute, XFL literals, and more).
rshooksno_std, ergonomic wrapper over rshooks-core — Result-based APIs, typed buffers, the XFL decimal-float type, guard/trace macros, and a panic handler.
rshooks-buildThe CLI that turns a Rust crate into one or more SetHook-valid WASM binaries: a discovery build plus one build per declared Hook, each post-processed by a hook-cleaner and guard-checker, natively in Rust.
rshooks-testenvAn off-chain unit-test harness with a mock Hook host, for testing Hook logic without WASM or a running Xahau node.

This book focuses on the ergonomic layer — the rshooks crate and its macros — since that’s what Hook authors write against day to day. The raw rshooks-core FFI bindings are covered briefly in the reference chapter for when you need to drop down to the bare host call, but every worked example in this book uses rshooks’s typed, Result-returning wrappers.

How this book is organized

  • Getting Started walks through installing the toolchain and building your first Hook, the minimal accept-all example, end to end.
  • Core Concepts covers the shape every Hook shares: the #[hooks] struct/impl declaration, entry points, accept/rollback and the error model, the loop-guard system, tracing, and the multi-Hook chain model.
  • Working with Data covers reading the originating transaction, Hook state, parameters, typed derives, the XFL decimal-float type, and the typed slot/keylet layers for reading ledger objects.
  • Emitting Transactions covers building and submitting a new transaction from inside a Hook.
  • Build Toolchain documents the rshooks CLI itself and the per-hook attributes it reads to generate a SetHook template.
  • Reference is a lookup appendix: the full macro list, the prelude’s contents, the raw FFI layer, and an index of the runnable examples in the repository.

Every code sample in this book is adapted from a real, runnable example in the rshooks repository’s examples/ directory — see the Examples Index for the complete, numbered list.

Installation

Building a Hook needs two things beyond a normal Rust setup: the wasm32v1-none compilation target, and the rshooks CLI that post-processes cargo’s output into a SetHook-valid binary. This page sets up both, plus the shape of a new Hook crate’s Cargo.toml.

Rust toolchain

rshooks targets a stable Rust toolchain, edition 2024. wasm32v1-none has been stable since Rust 1.84; the rshooks repository itself pins a specific version via rust-toolchain.toml:

[toolchain]
channel = "1.89.0"
targets = ["wasm32v1-none"]
components = ["rustfmt", "clippy"]

If you’re not using rustup’s toolchain-file auto-detection, add the target explicitly:

rustup target add wasm32v1-none

Installing the build CLI

cargo install rshooks-build

This installs a binary named rshooks (from the rshooks-build package) used throughout this book. It wraps cargo build --target wasm32v1-none and does not replace your regular cargo — you still need a working Rust install on PATH.

Adding rshooks to a new crate

cargo add rshooks

or add it to Cargo.toml directly. A minimal Hook crate looks like this:

[package]
name = "my-hook"
version = "0.2.3"
edition = "2024"

[lib]
crate-type = ["cdylib"]
# no_std cdylibs have no `test` crate for wasm32v1-none; disable the
# (impossible) unit-test harness target.
test = false

[dependencies]
rshooks = "0.2.3"

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"

A few things worth noting about this shape:

  • crate-type = ["cdylib"] — a Hook compiles to a C-compatible dynamic library; that’s the artifact rshooks post-processes into a .wasm binary. Plain cargo build output from a cdylib also exports memory, which rshooks strips along the way (SetHook rejects a module that exports anything besides hook/cbak).
  • The crate itself is #![no_std] (declared in src/lib.rs, not Cargo.toml) — there is no allocator, no std, and no panic machinery on the Hook host.
  • The release profile matters, not just for size but for correctness: opt-level = 3 (not the smaller "z") raises the byte threshold below which LLVM lowers a stack zero-init to inline stores instead of an unguarded memset-style loop, which avoids a class of build failures the guard checker would otherwise reject. lto = "fat", codegen-units = 1, panic = "abort", and strip = "symbols" all reduce final binary size, which matters directly: SetHook’s fee scales with the deployed binary’s byte count. This mirrors the profile the examples/ workspace itself uses — see the Building a Hook chapter for the pipeline this feeds into.

The host-panic-handler feature

rshooks ships a default panic-handler feature that rolls a Hook back on panic instead of leaving undefined behavior on the wasm target. That handler is gated to target_arch = "wasm32" and does nothing useful for a plain host cargo check — which matters because no_std cdylib crates like this one otherwise fail to type-check outside the wasm target (there’s no std, and no panic handler for the host target either). Enabling host-panic-handler provides a host-only panic handler purely so tools like rust-analyzer can run cargo check against your Hook crate on your own machine:

[dependencies]
rshooks = { version = "0.2.3", features = ["host-panic-handler"] }

Never enable this feature from a std context — it’s meant only to make host-side tooling work for an otherwise no_std Hook crate. It has no effect on the actual wasm32v1-none build.

With the toolchain, the CLI, and a crate shaped like this in place, you’re ready to write your first Hook.

Your First Hook

This chapter walks through accept-all, the minimal starter Hook: it traces a short message, then unconditionally accepts the transaction that triggered it. No loops, no state, no emitted transactions — a good template to copy for a new Hook and a good way to see every required piece in one small file.

Before the code, two concepts this whole book builds on:

  • The struct is a declaration vessel, not a runtime instance. A #[hooks] struct never gets constructed and never holds real data at runtime — it exists so a name, its fields, and the Hook entries that use them can all be declared in one place. Every entry receives this chain declaration by shared reference (&self), even AcceptAll, which declares no fields — later chapters, once a chain has declared fields worth reading, show how an entry reaches them as self.<field>.
  • Every Hook entry has an explicit index. The index is this crate’s Hook’s position in the account’s Hooks array (the one a SetHook transaction installs into) — position 0, 1, and so on, up to 9. Even a crate with exactly one Hook must say which position it occupies; there’s no implicit “the only one.”

The source

Create src/lib.rs in the crate you set up in Installation:

#![no_std]

use rshooks::*;

#[hooks(description = "Accepts every transaction selected by HookOn.")]
pub struct AcceptAll;

#[hooks]
impl AcceptAll {
    /// Accepts every triggering transaction.
    #[hook(0, name = "accept", on = [Invoke])]
    fn main(&self) -> exit::HookResult {
        trace!(b"accept-all: accepting transaction");
        accept!()
    }
}

This first hook exits through accept!() — the simplest thing that works before Ok/Accept are introduced. The idiomatic typed exit (Ok(Accept::from_code(0)) and ?-propagated errors) is covered in Accept, Rollback, and Errors; both compile to the same wasm here.

To see the trace! line actually run, enable the trace feature in Cargo.toml alongside rshooks:

[dependencies]
rshooks = { version = "0.2.3", features = ["trace", "host-panic-handler"] }

#![no_std]

Every Hook crate is #![no_std]: there’s no allocator and no std on the Hook host, and rshooks itself is no_std so it can be linked into one.

use rshooks::*;

This glob import brings in everything declared at rshooks’s crate root: the #[hooks] attribute macro, the XFL!/account_id! macros, the accept!/rollback!/trace!/guard! macro family, and every top-level module (api, types, xfl, …) by name. It does not bring the functions inside those modules into scope — a Hook that calls typed API functions like otxn_field or state also needs use rshooks::prelude::*;, which this minimal example doesn’t, since it never reads the transaction or touches state. Later chapters that do add that import.

#[hooks(description = "...")] on the struct

#[hooks] on struct AcceptAll; declares this crate’s Hook chain: a container for shared state/parameter fields (none here — AcceptAll is a unit struct, so there’s nothing to declare) plus the optional description, free-form text carried into the build’s generated sidecar. The struct name (AcceptAll) is yours to choose; it plays no on-ledger role.

#[hooks] on the impl block, and #[hook(0, ...)]

The second #[hooks] attribute, on impl AcceptAll, marks this as the chain’s entry-point block — exactly one such impl is required per #[hooks] struct. Inside it, #[hook(0, name = "accept", on = [Invoke])] declares one Hook entry:

  • 0 — the required, positional first argument: this Hook occupies position 0 in the Hooks array. Explained further in Hook Chains.
  • name = "accept" — the on-ledger HookName this Hook installs with (optional; omit it for an unnamed Hook).
  • on = [Invoke] — this Hook fires only for Invoke transactions. Omitting on entirely is also legal — see Per-Hook Attributes for what that means.

The annotated function itself is an associated function taking &self and returning exit::HookResult — reached here as exit::HookResult because use rshooks::*; already brought the exit module in by name (see above). AcceptAll declares no fields, so main never reaches through self here, but the receiver is still required — #[hooks] expands it into the wasm export the Hook host requires:

#[unsafe(no_mangle)]
pub extern "C" fn hook(_reserved: u32) -> i64 {
    ::rshooks::exit::EntryReturn::finish(AcceptAll::main(&AcceptAll))
}

The function’s own name (main here) is just a convention; what matters is the hook export it produces for this entry’s build. &self is the one receiver form #[hooks] accepts on an entry — no lifetime, not mut — and it’s how an entry reaches a chain’s declared fields once there are any to reach, covered in Hook State. Any other receiver shape (self, mut self, &mut self, self: T), a missing receiver, or a return type that doesn’t implement the sealed EntryReturn trait (in practice, anything but rshooks::exit::HookResult) is rejected at compile time with a pointed error rather than a malformed export. Use #[cbak(0)] the same way, on the same index, to declare the optional settlement callback for this entry.

accept!()

accept!() calls the host’s accept function and never returns — its return type is !. accept!(msg, code) additionally carries a trace message and a caller-chosen result code; the bare form used here accepts with no message and code 0. Its counterpart, rollback!(msg, code), rejects the transaction instead. Both are covered in more depth in Accept, Rollback, and Errors.

Building it

From the crate’s own directory:

rshooks build --out out

or from elsewhere, pointing at its manifest:

rshooks build --manifest-path my-hook/Cargo.toml --out my-hook/out

--out out picks where the build publishes its output; omit it and rshooks build publishes under <target>/rshooks/<crate-name> instead (see “What lands in out/” below). This compiles your crate once to discover its declared Hook(s), then once more per declared index, and post-processes each result — see Building a Hook for exactly what that pipeline does. A successful build prints something like:

discovery build (my-hook)
building entry 0 (`main`)
wrote out/current/0.main.wasm (171 bytes, estimated SetHook fee 855000 drops)
wrote out/current/0.main.metadata.json
wrote out/current/sethook.template.json
wrote out/current/sethook.template.meta.json

The guard checker’s/validator’s own numbers (worst-case instructions, max nesting depth) aren’t printed here — they land in the metadata sidecar below instead, or print on the terminal via rshooks check out/current/0.main.wasm; see Building a Hook for that report’s exact shape.

What lands in out/

rshooks build writes into a generation directory under its output root — out/ above, since --out out was passed; the default, if --out is omitted, is <target>/rshooks/<crate-name>, where <target> is cargo’s own target directory — with <root>/current symlinked to the latest generation (see Hook Chains for why generations exist). For AcceptAll, out/current/ contains:

  • 0.main.wasm — the cleaned, SetHook-valid binary for index 0: cargo’s raw cdylib output with the memory export stripped and every Hook API rule (§ single hook/cbak export, guarded loops, MVP-only instructions) validated. The file name is <index>.<fn>.wasm — one file per declared entry, so a multi-Hook chain gets one independent binary per index.
  • 0.main.metadata.json — this entry’s metadata sidecar:
{
  "index": 0,
  "hook_fn": "main",
  "cbak_fn": null,
  "name": "main",
  "description": null,
  "HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
  "HookCanEmit": null,
  "HookName": "616363657074",
  "HookHash": "68A7D6BD77FB98A7E05E0BFD12180352903F131B2780F848267C37DC0837707C",
  "WCE": {
    "hook": 14,
    "cbak": 0
  },
  "builder": {
    "name": "rshooks-build",
    "version": "0.2.3",
    "rustc": "rustc 1.89.0 (29483883e 2025-08-04)",
    "cargo_args": ["rustc", "--release", "--locked", "--target", "wasm32v1-none", "--crate-type", "cdylib"],
    "rustc_args": ["--cfg", "rshooks_entry=\"0\"", "--check-cfg", "cfg(rshooks_entry,values(\"0\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\"))", "-C", "link-arg=-zstack-size=131072"],
    "wasm_opt": true
  },
  "human": {
    "on": {
      "form": "list",
      "HookOn": ["Invoke"],
      "HookOnIncoming": null,
      "HookOnOutgoing": null
    },
    "HookCanEmit": null,
    "HookName": "accept"
  },
  "chain": {
    "struct": "AcceptAll",
    "description": "Accepts every transaction selected by HookOn.",
    "decls": { "state": [], "hook_params": [], "otxn_params": [] }
  }
}

builder records the toolchain provenance for this build. A crate built with the unstable-param-sig-interface feature additionally carries a sig_params field (the entry’s declared typed signature parameters); on a stable build like this one the key is absent entirely. The full grammar for every field here is covered in Per-Hook Attributes.

  • sethook.template.json / sethook.template.meta.json — a ready-to-edit SetHook transaction template covering every index this crate declares, plus a sidecar recording how it was generated. Covered in full in Hook Chains and Per-Hook Attributes.

The WCE (worst-case execution) numbers are the static, guard-derived upper bound on instructions the host will ever execute for this entry’s hook/cbak — the same figures the pipeline printed to the terminal. The HookHash is Xahau’s hash of the deployed binary: the uppercase hex of the first 32 bytes of the wasm’s SHA-512 digest — this is what identifies the exact Hook code on-ledger, independent of which account installed it. The chain object transcribes this crate’s shared struct-level schema (empty here, since AcceptAll declares no fields) — every entry’s sidecar carries the same chain object, since the schema is shared across the whole crate, not owned by any one entry.

From here, Building a Hook explains what each pipeline stage actually does, Hook Chains covers the multi-Hook model this build pipeline exists for, and The rshooks CLI is the complete flag reference for every subcommand.

Building a Hook

The previous chapter ran rshooks build without explaining what it actually does. This chapter walks through the pipeline stage by stage, so the printed report and the check subcommand make sense on their own. A crate can declare more than one Hook (see Hook Chains); this chapter describes the pipeline for one declared entry, and the next section covers how it repeats.

The pipeline

rshooks build runs cargo, then a fixed sequence of post-processing and validation steps:

  1. Discovery build — cargo build --release --target wasm32v1-none once, with no entry selected, compiling every declared Hook and cbak into one artifact. This build is never deployed; its only purpose is to read back the crate’s declarations (the #[hooks] struct’s shared schema and every #[hook]/#[cbak] entry’s metadata), extracted from dead, hex-encoded carrier exports the macros generate and this artifact alone carries.
  2. Per-index build, once per declared entry — for each index the crate declares, cargo rustc --release --target wasm32v1-none -- --cfg 'rshooks_entry="<i>"' recompiles the same crate with that one entry selected. The --cfg flag steers the same #[hooks]-generated code to export exactly hook (and cbak, if this index declares one) instead of the discovery build’s suffixed names — this is the only artifact that’s ever SetHook-valid for that index. The tool then re-extracts this build’s own carriers and checks them byte-for-byte against discovery’s — a mismatch (a build script or cfg-sensitive macro producing different declarations at a different --cfg value) is a build error naming exactly which entry and field diverged.
  3. Hook-cleaner (per index) — strips the disallowed memory export, the now-redundant carrier exports, and any other dead export, and flattens and inlines the crate’s call graph into the hook/cbak entry points for this index only, untangling the resulting block/loop/if nesting so it fits the host’s structural limits. Because each index is compiled and cleaned separately, one index’s unreachable code (another entry’s logic, in a multi-Hook chain) never counts against this index’s own size or nesting budget.
  4. Guard checker (per index) — validates that every loop begins with the exact guard call sequence the host requires, and computes the static worst-case instruction count (WCE) for hook and, if present, cbak, from those guards.
  5. Validator (per index) — checks the complete SetHook rule set: exactly one hook export (and at most one cbak, and only if this index declared one), no disallowed imports, no recursion, and a binary size at or under the 65,535-byte SetHook limit (unless --allow-oversize is passed, in which case the output is still written but clearly marked invalid).
  6. Sidecar and template generation — once every index has been built and validated, writes one <index>.<fn>.metadata.json sidecar per entry, then a sethook.template.json covering every declared index in one Hooks array, plus its sethook.template.meta.json generation sidecar. Covered in Hook Chains and Per-Hook Attributes.
  7. Publish — stages every artifact from this run, then atomically updates the output root’s current entry (out/current below — wherever --out points, or <target>/rshooks/<crate-name> by default; see Your First Hook) to point at it. A failed run never touches current; it always resolves to the most recent complete, validated build.

Every wasm-producing step runs against the exact bytes that will be deployed — the WCE and HookHash recorded in that entry’s metadata sidecar describe the file actually written to out/current/, not an intermediate artifact.

Reading the printed report

build prints its progress as it goes: a discovery line, one building entry <index> (...) line per declared entry, then a wrote ... line for every published artifact once the whole chain has built successfully:

discovery build (accept-all)
building entry 0 (`main`)
wrote out/current/0.main.wasm (171 bytes, estimated SetHook fee 855000 drops)
wrote out/current/0.main.metadata.json
wrote out/current/sethook.template.json
wrote out/current/sethook.template.meta.json

build itself doesn’t print the guard checker’s or validator’s numbers — those land in that entry’s <index>.<fn>.metadata.json sidecar (the WCE object) instead. To see them on the terminal, run rshooks check against the binary build just wrote:

$ rshooks check out/current/0.main.wasm
worst-case instructions: hook=14 cbak=0
max nesting depth: 0
OK: out/current/0.main.wasm is a valid SetHook wasm binary
size: 171 bytes
estimated SetHook fee: 855000 drops (0.855000 XAH)
  • worst-case instructions is the guard checker’s static upper bound on instructions the host will ever execute for each entry point.
  • max nesting depth is the deepest block/loop/if nesting in the final module, checked against the host’s structural limit — 32 for a Guard-type module. This is the number Hook Chains covers in more depth: dense use of the typed #[state]/#[hook_param]/ #[otxn_param] accessors at one call site can push it close to that ceiling.
  • size / estimated SetHook fee are computed directly from that entry’s final binary byte count — SetHook’s fee schedule is bytes × 5000 drops, so this is the actual one-time deployment fee cost of the binary you just built, not an approximation. Because each index is its own independent wasm, each has its own size and its own fee — a multi-Hook crate’s total deployment cost is the sum across every declared index.

Validating a binary without building it

rshooks check <file> runs the same guard-checker and validator steps against an existing wasm file, without invoking cargo or writing any output. It works on any SetHook-shaped wasm, including one this toolchain didn’t build — see The rshooks CLI for its full flag reference, and build’s and clean’s as well.

A note on compiler-generated loops

Guards are your responsibility: an unguarded loop is treated as a hard build error, on the principle that a missing guard! in your own source is a bug, not something the toolchain should paper over. This includes loops the compiler generates that never appear in your Rust source at all (certain array-equality and buffer-zeroing patterns can lower to an unguarded loop at the WASM level). The source-level idioms that avoid those loops entirely are covered in the Guards and Loops chapter.

Anatomy of a Hook

A Xahau Hook is a small WebAssembly module with one required export and one optional export. This page walks through what an rshooks hook crate looks like from top to bottom: the crate shape, the #[hooks] struct/impl declaration, how a hook’s execution model shapes the way you write code, and the statics idiom used for templates and large buffers. Understanding this shape first makes the rest of the book — data access, errors, guards — much easier to place. This page covers a single-entry crate; Hook Chains extends the same shape to a crate declaring more than one Hook.

The crate shape

Every hook crate is a no_std cdylib:

#![no_std]

use rshooks::prelude::*;
use rshooks::*;

#[hooks(description = "Accepts every transaction selected by HookOn.")]
pub struct AcceptAll;

#[hooks]
impl AcceptAll {
    #[hook(0, name = "accept", on = [Invoke])]
    fn main(&self) -> HookResult {
        trace!(b"accept-all: accepting transaction");
        Ok(Accept::from_code(0))
    }
}

(adapted from examples/01_accept-all/src/lib.rs.) A few things to notice:

  • #![no_std] — there is no heap, no OS, no std:: anything. rshooks’s prelude module gives you the ergonomic surface (typed accessors, macros, common types) without depending on std.
  • The struct (AcceptAll) is this crate’s chain-declaration vessel — a place to name shared state/parameter fields (none here) and carry a build-only description. It’s never constructed and holds no runtime data; see “The struct has no runtime instance” below.
  • The impl block, also annotated #[hooks], is where the actual entry functions live, each marked with #[hook(<index>, ...)] or #[cbak(<index>)]. name/on/can_emit/description are per-entry attributes now, rather than a separate top-level declaration — covered in full in Per-Hook Attributes.

#[hooks]: struct and impl, always as a pair

Every chain needs exactly one #[hooks] struct and exactly one #[hooks] impl block for it, in the same module — the two halves are linked by name (impl AcceptAll refers back to struct AcceptAll), and the macros generate a compile-time handshake between them, so an impl with no matching annotated struct (or vice versa) fails to compile with a dedicated error rather than silently doing nothing.

The struct itself can be a plain unit struct (struct AcceptAll;, as above, when there’s no state or parameters to declare) or a named-field struct whose fields carry #[state]/#[hook_param]/ #[otxn_param] attributes — covered in Hook State and Hook and Transaction Parameters. Moving from one to the other is exactly “replace the trailing ; with a field block”; nothing else about the declaration changes.

The struct has no runtime instance — but every entry borrows it

This is worth stating plainly, because Rust’s struct/impl syntax normally implies an object with methods that take self. Here, you never construct one. For a struct with declared fields, the macro generates its own single, zero-sized instance for you — a static named the same as the struct (static Vault: Vault) — existing purely so its fields’ declared state/parameters have something to hang accessor methods off. AcceptAll is a unit struct (struct AcceptAll;) with no fields to hang anything off, so no static is generated for it at all; AcceptAll the identifier already names its own unit value (Rust gives every unit struct exactly one, for free), and that’s the value &self borrows in its entry above.

Every entry takes &self to receive that value by shared reference, and reads any declared field through its kind’s namespace — self.state.some_field, self.hook_param.some_field, or self.otxn_param.some_field — the canonical style whenever an entry, or a helper function inside the same impl, touches a declared field:

#[hooks]
impl StateCounter {
    #[hook(0, on = [Invoke])]
    fn main(&self) -> HookResult {
        let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);
        // ...
    }
}

Code outside the impl — a free function, another module — has no self to borrow, so it reaches the identical static by the struct’s own name instead: StateCounter.state.counter.get(). Both spellings name the same zero-sized value; &self is a reference to a zero-sized value, so it optimizes away completely, even across an #[inline(never)] boundary. Use &self inside the annotated impl and the struct-name static everywhere else.

The one receiver an entry accepts is bare &self — no lifetime, not mut, and not optional. A missing receiver, or any other self-receiver shape (self, mut self, &mut self, self: T), is a compile error with a dedicated diagnostic rather than a type mismatch: chain handles are zero-sized and immutable, so there is nothing to own or write through — only to read through a shared reference. A non-attributed helper function declared inside the same impl is less strict: it accepts either no receiver or &self, whichever its own body needs.

Entry functions: #[hook(<index>, ...)] and #[cbak(<index>)]

The Hook host requires a wasm export shaped like extern "C" fn hook(_reserved: u32) -> i64. Writing that by hand means an unsafe extern "C" function signature in every hook crate. #[hooks] avoids that: it takes a plain associated function and generates the export for you, per selected build (see Building a Hook for what “per selected build” means).

#[hooks]
impl AcceptAll {
    #[hook(0)]
    fn main(&self) -> HookResult {
        Ok(Accept::from_code(0))
    }
}

expands, for index 0’s own build, to the original function unchanged, plus:

#[unsafe(export_name = "hook")]
pub extern "C" fn __rshooks_hook_sel_0(_reserved: u32) -> i64 {
    ::rshooks::exit::EntryReturn::finish(AcceptAll::main(&AcceptAll))
}

The macro enforces the annotated item’s shape exactly. Receiver, modifier, and generic violations are a compile_error! at the offending token rather than a panic; a return type that does not implement the sealed EntryReturn trait is an ordinary E0277 naming EntryReturn:

  • a bare &self receiver (see above), optionally followed by one further argument;
  • a return type implementing the sealed EntryReturn trait (currently, only rshooks::exit::HookResult);
  • no async/unsafe/const/extern modifiers;
  • no generics, no where clause.

The annotated function’s own name is arbitrary — main is just a convention carried through every example in this book. What matters is the hook export it produces for its declared index.

#[cbak(<index>)] is the counterpart for the same index: a Hook entry can optionally have one, generating a cbak export instead of hook. The host invokes it when a transaction the hook previously emitted (via emit) later settles on ledger, so the hook can react to its own emission’s outcome. Its fn may declare one argument after &self — EmitOutcome (or a raw u32) — decoded from the host’s own cbak(u32) argument; #[hook] instead accepts signature-parameter arguments there (see Hook and Transaction Parameters). See Emitting Transactions for a worked #[cbak] example. Both attributes take one required argument — the index — plus, for #[hook], the optional named metadata arguments covered in Per-Hook Attributes.

Execution model

Each Hook invocation runs in a freshly instantiated wasm instance: there is no persistent process, no threads, and no state carried in memory from one invocation to the next (anything that needs to persist belongs in Hook State, not a Rust static’s runtime value). A hook must always terminate by calling into the host’s accept or rollback — covered in Accept, Rollback, and Errors — there is no implicit “fall off the end and succeed.”

This single-threaded, single-shot model is also what makes the statics idiom below sound: nothing else can be running concurrently, or left over from a previous call, that could alias a static’s contents.

Source style: what a hook avoids

Because a hook has a small, fixed instruction budget enforced by the guard system (see Guards and Loops) and no defined behavior for an unhandled panic on the Hook host, rshooks hooks are written to avoid panicking operations entirely, not merely to survive them:

  • No slice indexing or range-slicing with a non-literal index (use .get()/.get_mut(), which return Option, instead) — a literal, provably in-bounds index on a fixed-size array is fine.
  • No format!/core::fmt — trace!, accept!, and rollback! all take raw byte slices, not formatted strings.
  • No unwrap/expect/panic! — every Result is handled explicitly, typically by rolling back on Err.
  • Runtime arithmetic uses checked or wrapping operators (.wrapping_add(), .checked_add(), and so on) rather than bare +/-/ * on non-constant values, which can panic on overflow.

These rules are what the panic handler below exists to catch when something still slips through — not the primary correctness mechanism.

The panic handler

rshooks ships a #[panic_handler] for wasm builds, enabled by the default-on panic-handler feature: if a hook ever does panic, it rolls back with a fixed message (b"panic") and a distinctive code, -999_999, chosen well outside the documented Hook API error range (-1..=-45, plus -10024) so it can never be confused with a real error code. This is a last-resort backstop for an unhandled panic, not something to design around — the style rules above are what keep a hook out of this path in the first place. A hook can disable the default feature and supply its own handler instead.

A second, non-default feature, host-panic-handler, exists purely so a no_std hook crate can be cargo checked on a host target (what rust-analyzer runs for completion and diagnostics) — a no_std crate needs some #[panic_handler] even for host analysis, but the wasm handler above is target-gated. Enable it only for host analysis; it is never reached in an actual hook execution, since a host build of a hook crate is for analysis only.

Statics for templates and large buffers

Constant byte templates and large output buffers should live in statics, not stack locals. The reason is codegen, not style: a stack-local array literal is materialized at runtime by a chain of store instructions (real code bytes, counted against the worst-case instruction count), while a static template becomes a wasm data segment costing exactly its own bytes — no runtime code at all. A large zero-initialized stack buffer is worse: it compiles to a compiler_builtins memset-style loop, an unguarded loop that never appears as a loop keyword anywhere in your source, while a zero-initialized static lands in linear memory’s BSS — zero bytes of data segment, zero code, because wasm memory is zero-initialized by definition.

rshooks::static_cell::HookStatic (re-exported from the prelude) is the safe way to declare one:

static TXN: HookStatic<Payment> = HookStatic::new(Payment::new());
let Some(txn) = TXN.take() else {
    // already taken — see below for why this can only happen once
};

(adapted from examples/10_emit-txn/src/lib.rs.) HookStatic::new is const, so the value’s bytes land in a data segment (or BSS, if all-zero) exactly as described above. take() hands out the buffer’s one exclusive &'static mut on the first call; every call after that returns None.

That exclusivity is what makes HookStatic sound without any unsafe at the call site: two aliasing &mut references to the same static can never be produced, because at most one caller can ever win the take. This safety argument leans directly on the execution model above — a hook runs single-threaded, and every invocation gets a freshly instantiated wasm instance, so “handed out at most once” really does mean “at most once, ever,” with no way for a left-over reference from a prior call to still be alive. There is deliberately no “give back” operation on HookStatic: a hook runs once and exits.

Converting a real example (emit-txn) to this idiom removed its only compiler-generated loops entirely and cut its worst-case instruction count by an order of magnitude — see Guards and Loops for the guard system this interacts with, and why compiler-generated loops are worth avoiding rather than just guarding after the fact.

Where to go next

Hook Chains

Anatomy of a Hook covered a #[hooks] struct/impl pair declaring a single Hook. Nothing about that shape is actually limited to one entry: the same struct can carry more than one #[hook(<index>, ...)] (and matching #[cbak(<index>)]) in its impl block, each with its own index. This page covers what changes when it does — how index maps to on-ledger position, why a shared struct is the model’s biggest win, what a multi-Hook build actually produces, the SetHook template’s exact semantics, and a real, measured limit worth knowing about before you lean on this model too hard in one entry.

One struct, one chain

A single #[hooks] struct plus its one #[hooks] impl is this crate’s entire chain declaration — not “a Hook,” but everything this crate contributes to an account’s Hook chain, across however many indices it declares. There is deliberately no way to have two chains in one crate: each #[hooks] struct generates a fixed-name linker symbol, so a second one collides and fails to link. One crate, one chain.

Index: chain position, not just an artifact ID

#[hook(<index>, ...)]’s leading integer means two things at once:

  1. Which artifact this entry becomes — its own wasm, its own metadata sidecar, built and validated independently of every other entry in the crate (see “What a chain build produces,” below).
  2. Where it sits in the account’s Hooks array — the same array a SetHook transaction installs into. Index 0 is Hooks[0], index 3 is Hooks[3], and so on.

Valid indices are 0..=9 (a SetHook transaction’s Hooks array holds at most 10 entries), and gaps are allowed: a crate can declare only 0 and 2, leaving position 1 for something else entirely (a different crate’s Hook, installed and managed separately). What gaps mean for the generated template is covered below.

#[cbak(<index>)] pairs with a #[hook] at the same index — it doesn’t take its own name or trigger, just the index it settles for. An index can have a hook with no cbak; an index with a cbak but no hook is a compile error (there’d be nothing for it to settle), and a crate declaring zero hooks at all is also a compile error — a chain with nothing in it isn’t a useful chain.

Because index is written in the source, changing which position a Hook occupies is a source change, not a deployment-time choice: reviewing a diff to #[hook(1, ...)] is reviewing exactly what’s moving where in the account’s chain. If you need to install the same compiled wasm at a different position without touching source, the generated template’s Hooks array (below) can still be reordered by hand before submission — the wasm itself doesn’t encode its own position.

The shared schema: why this is the model’s biggest win

Every field on the #[hooks] struct — every #[state], #[hook_param], #[otxn_param] — is declared exactly once and can be referenced from any entry in the same impl block. This is the actual payoff of the model: state or parameters two Hooks in the same chain both touch get one Rust-level declaration, type-checked once, instead of one copy per crate silently drifting out of sync.

examples/80_governance is the worked example — a Rust port of xahaud’s genesis govern/reward pair, which on the real network are installed side by side, Hooks[0] = govern and Hooks[1] = reward. The two entries share a state layout (the reward rate/delay, and the seat table) that neither one exclusively owns — declaring them as one chain gives that shared layout a single Rust-level declaration, instead of leaving it to be duplicated (and potentially drift) across two independent crates. As one chain:

#[hooks(description = "20-seat L1/L2 governance and reward chain")]
pub struct Governance {
    /// L1 reward rate. Written by governance; read by both governance
    /// and reward.
    #[state(key = b"RR")]
    reward_rate: State<XFL>,

    /// L1 reward delay (seconds). Same story as `reward_rate`.
    #[state(key = b"RD")]
    reward_delay: State<XFL>,

    // ... member_count, seat_forward, member_reverse, and this chain's
    // hook parameters, all declared once here.
}

#[hooks]
impl Governance {
    #[hook(0, on = [Invoke], can_emit = [Invoke, SetHook])]
    fn govern(&self) -> HookResult { /* ... reads and writes self.state.reward_rate/self.state.reward_delay */ }

    #[hook(1, on = [Invoke, ClaimReward], can_emit = [GenesisMint])]
    fn reward(&self) -> HookResult { /* ... reads self.state.reward_rate/self.state.reward_delay */ }
}

Both entries declare a &self receiver and reference self.state.reward_rate/self.state.reward_delay directly — there is exactly one Rust type for that state entry, so govern’s write and reward’s read can never silently disagree about the key’s shape or the value’s layout. (The real examples/80_governance crate’s dense govern/setup path writes reward_rate/reward_delay through the raw API instead — see “A real limit,” below, for why — while reward’s own two reads still go through the typed self.state.reward_rate/self.state.reward_delay accessors shown here; this sketch shows the model at its cleanest.)

One nuance worth being precise about: the struct shares the schema, not the values. Both entries read/write the identical on-ledger state key — that part genuinely is shared — but a Hook parameter declared on the struct is installed independently per index. Governance’s config field (if it had one) could be installed with one value at index 0 and a different value at index 1; the struct only guarantees both entries agree on the parameter’s shape, not that they were configured identically.

What a chain build produces

rshooks build compiles a multi-Hook crate once per declared index (see Building a Hook for the discovery-plus- per-index pipeline), producing, for Governance above (with --out out passed, per Your First Hook):

out/current/
  0.govern.wasm
  0.govern.metadata.json
  1.reward.wasm
  1.reward.metadata.json
  sethook.template.json
  sethook.template.meta.json

Each <index>.<fn>.wasm is a complete, independent wasm module: only the code reachable from that one entry’s own #[hook]/#[cbak] functions gets compiled in — govern’s logic never appears in 1.reward.wasm, and vice versa. The direct consequence is that the 65,535-byte SetHook size limit, and the 32-level structural nesting limit the guard checker enforces, apply per index, not to the crate as a whole. A chain of ten entries effectively has ten times the budget of one entry, split across ten independent artifacts, rather than one shared pool.

The output root itself is generation-numbered (<root>/gen-<N>/, with <root>/current a symlink to the latest complete, validated one — out/ above, or <target>/rshooks/<crate-name> if --out is omitted) so a build in progress, or one that fails partway through, never leaves current pointing at a half-written result.

The SetHook template: an owned-position patch, not a full chain

sethook.template.json is a ready-to-edit SetHook transaction covering every index this crate declares — but it is deliberately not a declarative statement of what the whole account’s chain should look like. It’s a patch over the positions this crate owns:

{
  "TransactionType": "SetHook",
  "Account": "<ACCOUNT>",
  "Hooks": [
    { "Hook": { "CreateCode": "<hex of 0.govern.wasm>", "...": "..." } },
    { "Hook": { "CreateCode": "<hex of 1.reward.wasm>", "...": "..." } }
  ]
}

The Hooks array is exactly as long as the highest declared index plus one — 0..=max, no padding beyond it. Every gap (a position with no declared entry, but below the highest one) becomes an empty {"Hook": {}} object: SetHook’s own no-op spelling for “leave whatever’s at this position alone.” That’s a deliberate, load-bearing distinction — {"Hook": {}} means “don’t touch this slot,” not “this slot is empty.” Two things follow directly from that:

  • Submitting this template never removes or overwrites a Hook at a gap position, or at any position past the array’s end, even if one is already installed there.
  • The template therefore does not guarantee the account’s chain matches this crate’s source after submission — only that this crate’s own declared positions end up matching. If something else is installed at a gap or beyond, it’s still there afterward; reconciling the whole account’s chain against source is outside what a generated template does.

The generated template is also fail-closed by default: it carries no Flags field at all, so submitting it as-is only succeeds against currently-empty declared positions — it will not silently overwrite an existing Hook. Pass --override at build time to add hsfOVERRIDE to every declared (non-gap) position, permitting replacement; gap objects never receive Flags, since adding one would turn a no-op into a real operation.

Account and HookNamespace are left as placeholders ("<ACCOUNT>", "<NAMESPACE>") unless you pass --account/--namespace at build time — there’s no way for the build to know which account this template is meant for. sethook.template.meta.json, alongside it, is generation provenance (not itself part of the transaction): hook hashes, the declared/gap position lists, and the amendment set the declared fields require. Both files’ exact shape — and the full per-entry attribute grammar that drives them (name, on, can_emit, and so on) — are covered in Per-Hook Attributes.

A real limit: typed-accessor density inside one entry

This is a genuine, measured constraint, not a style preference, and it’s the main reason to actually read this section rather than skim it.

Every layer the typed #[state(..)]/#[hook_param(..)]/#[otxn_param(..)] accessors go through — .at(..) → .get() → the underlying host call → decode — is zero-cost at the Rust level (every layer is #[inline(always)]). But the Guard-type build pipeline’s cleaner stage force-inlines everything reachable into one hook() body, regardless of Rust-level inlining hints, and then has to fit the result under the host’s 32-level structural nesting limit. A single entry with many sequential typed-accessor call sites in the same function accumulates nesting from each one — #[inline(never)] on a helper doesn’t exempt its own call sites from this, since the constraint is call-site density within whatever function ends up holding them, not which function that happens to be.

examples/80_governance hit this directly: govern’s setup path, with roughly fifteen typed-accessor call sites across a few helper functions, compiled to a post-cleaning nesting depth of nearly twice the limit of 32. Reverting exactly those dense call sites to the underlying raw API (same section below) brought it well under the limit; the committed depth is in examples/80_governance/metrics.json.

Every other example in this book stays comfortably under budget — this shows up specifically at governance’s call-site density, in one Guard-type entry. It’s worth knowing about before you assume the typed layer scales to an arbitrarily dense entry, not something to preemptively work around in an ordinary hook.

The escape hatch: the raw API, same declared bytes

When one entry’s typed-accessor density pushes past budget, the fix isn’t to abandon the struct’s shared declaration — it’s to keep the declaration (so the schema is still centrally documented and type-checked) but read or write the same key/name bytes through the lower-level free functions at just the dense call sites:

// Governance.state.reward_rate's own declared key is b"RR" — this hits the
// identical ledger slot, just without going through the typed accessor.
if state_set(value, b"RR").is_err() {
    GovernError::AssertionFailed.nope(b"Governance: Assertion failed.");
}

Because the raw call uses the field’s own declared literal, it addresses exactly the same on-ledger entry the typed accessor would — this is a call-site choice about which API shape to go through, not a second, diverging declaration. See Hook State and Hook and Transaction Parameters for the raw state/state_set/hook_param/otxn_param layer this falls back to, and examples/80_governance/metrics.json for the current measured numbers behind this section.

Where to go next

  • Per-Hook Attributes is the complete grammar for name/on/on_incoming+on_outgoing/can_emit/description, plus the exact shape of the generated sidecar and SetHook template JSON.
  • Hook State and Hook and Transaction Parameters cover the #[state]/#[hook_param]/ #[otxn_param] field declarations this page assumes.
  • The rshooks CLI covers --account/--namespace/ --override and every other build flag in full.

Accept, Rollback, and Errors

A Hook always terminates by returning rshooks::exit::HookResult from its #[hook(<index>, ...)]/#[cbak(<index>)] entry: Ok keeps the originating transaction’s effects, Err discards them. This page covers rshooks’s Result/HookError type for Hook API failures, how an entry actually exits (HookResult, Accept, Rollback, and ? propagation), the accept!/rollback! macros — the in-body escape hatch a typed entry falls back to for a computed message or a raw, zero-indirection body — and hook_errors!/exit_on_err!, the idiom this crate provides for giving a hook its own meaningful, stable error-code system instead of one undifferentiated rollback code everywhere.

HookError and Result

Every Hook API function returns an i64: a non-negative value is a success payload (often a byte count, or a slot/field-pointer value), and a negative value is one of the documented error codes from the Hook API. rshooks::error::HookError wraps that negative code directly — it is a #[repr(transparent)] newtype over the raw i64, not a decoding enum, so building one and reading the code back out are both the identity:

use rshooks::error::HookError;

let err = HookError::from(-5);
assert_eq!(err, HookError::DoesntExist);
assert_eq!(err.code(), -5);

Every wrapper in rshooks::api::* and rshooks::xfl returns rshooks::error::Result<T> — a plain type alias for core::result::Result<T, HookError> — so a failed host call surfaces as an ordinary Err(HookError::SomeConstant) you can compare against, rather than a raw negative integer. Each named Hook API error has an associated constant with that name (HookError::DoesntExist, HookError::TooBig, …). HookError is not an enum, so those constants are not patterns; matching a specific error is a guard (Err(e) if e == HookError::DoesntExist => ..), and HookError::kind() returns a HookErrorKind — an ordinary enum, with HookErrorKind::Unknown for any code without a named constant — for exhaustive dispatch.

It’s worth being precise about what HookError represents: it’s about why a host call failed (out of bounds, doesn’t exist, invalid argument, and so on) — not about why your hook rejected the transaction. That second concept is what the rest of this page covers.

Typed entry returns: HookResult

Every #[hook]/#[cbak] entry returns rshooks::exit::HookResult — a Result<Accept, Rollback> alias (rshooks::prelude re-exports Accept/Rollback/HookResult; the module path is rshooks::exit). Ok(Accept::new(msg, code)) accepts; Err(Rollback::new(msg, code)) rolls back; ordinary ? propagates a failure out of a helper function, in place of a hand-written accept!/rollback! call at every failure point:

use rshooks::exit::{Accept, HookResult};

hook_errors! {
    pub enum DepositError {
        BadAmount = 1 => b"deposit: bad amount",
        StateSetFailed = 2 => b"deposit: state_set failed",
    }
}

#[inline(always)]
fn read_amount(t: &Vault) -> Result<u64, DepositError> {
    let bytes = t.amount.get_required().map_err(|_| DepositError::BadAmount)?;
    Ok(u64::from_be_bytes(bytes))
}

#[hook(0, on = [Invoke])]
fn deposit(&self) -> HookResult {
    let amount = read_amount(self)?;
    // ... more `?`-propagated steps ...
    Ok(Accept::new(b"deposit: ok", amount as i64))
}

Internally, the generated wrapper calls a sealed ::rshooks::exit::EntryReturn::finish(..) on whatever the entry returns: a two-arm match calling accept/rollback on the host. HookResult is the only type implementing that sealed trait, so it’s the only return shape #[hooks] accepts on an entry or callback — returning anything else is a compile error naming EntryReturn, not a bespoke macro diagnostic. examples/16_typed-results is the full worked example.

Accept, Rollback, and the hook_errors! message clause

Accept::new(msg, code)/Rollback::new(msg, code) mirror accept!/ rollback!’s own arguments; Accept::from_code(code)/ Rollback::from_code(code) are the empty-message shorthand, and .msg()/.code() read either type’s fields back. ? converts into Rollback from any hook_errors! enum — every enum gets impl From<Enum> for Rollback unconditionally, and hook_errors! accepts an optional per-variant message clause that feeds it:

use rshooks::hook_errors;

hook_errors! {
    pub enum DepositError {
        /// Message clause: this variant's `Rollback` carries it.
        BadAmount = 1 => b"deposit: bad amount",
        /// No clause: this variant's `Rollback` gets an empty message.
        StateSetFailed = 2,
    }
}

The clause is per-variant — some variants may carry one and others not, in the same enum — and an enum with no clause anywhere still gets the From<Enum> for Rollback impl, with every message empty, so ? works uniformly whether or not any variant bothers with a message. A raw code is constructed with Err(Rollback::from_code(code)) (or Rollback::new), not Err(code_i64)?.

The #[inline(always)] helper convention

Every helper function called on a ? path inside a typed entry should be #[inline(always)], as read_amount is above. Measured (.claude/design/TYPED_ENTRY_RESULTS_DESIGN.md §5’s p2fix probe): the same logic through a plain (not force-inlined) Result-returning helper costs a handful of extra worst-case instructions — an un-inlined call-boundary cost, not a ?/Result cost — while force-inlined, the identical typed code measured below its hand-written accept!/ rollback! twin. examples/16_typed-results follows this convention throughout.

The one hard rule: never ? a raw HookError into Rollback

There is no From<HookError> for Rollback impl, and none is planned. A Hook API error code (-1..=-45, -10024) is not the hook’s own HookReturnCode — the two are different code spaces that happen to share the i64 representation — so an implicit ?-propagated conversion from a raw HookError into Rollback would publish the host’s code as the hook’s own verdict, which is never the right default.

The supported pattern for a fallible Hook API call inside a typed entry is .map_err(..), discarding the HookError and keeping only “some call failed”:

let value = some_hook_api_call().map_err(|_| MyError::SomeCallFailed)?;

— exactly what read_amount does above. Fall back to accept!/ rollback! directly (see below) when a computed, non-'static message is needed, or when dispatching on the specific error via HookError::kind() is genuinely required.

accept! and rollback!: the in-body escape hatch

accept!/rollback! are macros that end execution immediately, calling straight into the host’s accept/rollback — usable inside a typed entry’s body, not a competing return shape for the entry itself. Reach for them when:

  • A message needs to be computed at runtime. Accept::new/ Rollback::new take a &'static [u8], so a formatted or otherwise non-'static message has nowhere else to go.
  • An entry’s body is written in a raw, zero-indirection style, with no Result/? plumbing at all — examples/80_governance’s dense govern/reward entries are exactly this case (see that example’s own README.md for the measured reasoning).

Both macros accept the same two grammars:

accept!();                 // no message, code 0
accept!(msg, code);        // message bytes + application-defined code

rollback!(msg, code);      // rollback always takes a message and a code

msg is a raw byte slice (&[u8], typically a byte-string literal like b"done") — never a formatted string, since core::fmt/format! aren’t used in hook code (see Anatomy of a Hook). code may be a plain i64 literal, or any value whose type implements Into<i64> — which is exactly what hook_errors! gives you below, so rollback!(msg, my_enum_variant) works directly, without an explicit .code()/i64::from(..) call at the call site.

Both macros expand to a call that, on the real wasm host, never returns — execution unwinds immediately. That’s why rollback!’s return type is ! (the never type): it type-checks against whatever the surrounding match/if arm needs to produce, including a typed entry’s own HookResult — a branch that calls rollback! coerces to Ok/Err just as readily as it coerces to a plain value, so an entry mixing ?-propagated helpers with a direct rollback! call needs no placeholder return anywhere.

Designing meaningful error codes with hook_errors!

The code argument to rollback!/accept! isn’t discarded: xahaud records it in the transaction’s metadata as HookExecution.HookReturnCode. If every rejection path in a hook calls rollback!(msg, -1) with the same code, nothing inspecting the transaction afterwards — an indexer, a wallet, a support script — can tell why it was rejected without parsing the message text. hook_errors! is how rshooks hooks avoid that: one variant per rejection reason, each with its own explicit, stable discriminant.

Here’s the worked example from examples/04_errors, a hook that rejects a Payment for one of four distinct reasons:

use rshooks::hook_errors;

hook_errors! {
    /// Rejection reasons returned by this hook.
    pub enum RejectReason {
        /// The originating account could not be read.
        BadAccountField = -101,
        /// The source tag is blocked.
        BlockedSourceTag = -102,
        /// The amount is not native.
        NotNativeAmount = -103,
        /// The amount exceeds the policy limit.
        AmountTooLarge = -104,
    }
}

hook_errors! expands this into a #[repr(i64)], Debug + Clone + Copy + PartialEq + Eq enum with the given variants and discriminants, plus:

  • impl From<RejectReason> for i64;
  • an inherent fn code(self) -> i64 — the same conversion as a method, for call sites that prefer err.code() over i64::from(err);
  • impl From<RejectReason> for Rollback — unconditional, msg taken from an optional per-variant message clause (see “Accept, Rollback, and the hook_errors! message clause” above), so ? works on any Result<T, RejectReason> even though this example doesn’t use one.

Each variant requires an explicit i64-valued discriminant — the macro’s grammar enforces this — and negative discriminants work the same as positive ones, as in the example above. The example crate then adds a small hand-written impl for the parts the macro doesn’t generate — a message per variant, and a rollback convenience:

impl RejectReason {
    fn message(self) -> &'static [u8] {
        match self {
            RejectReason::BadAccountField => b"errors: could not read otxn Account",
            RejectReason::BlockedSourceTag => b"errors: blocked SourceTag",
            RejectReason::NotNativeAmount => b"errors: unsupported (non-native) Amount",
            RejectReason::AmountTooLarge => b"errors: amount exceeds policy limit",
        }
    }

    fn rollback(self) -> ! {
        rollback!(self.message(), self)
    }
}

rollback!(self.message(), self) relies on the Into<i64> impl hook_errors! generated: self (a RejectReason) converts through i64::from on its way into the host call, with no .code() needed at the call site. The hook body then runs a short chain of checks, calling RejectReason::rollback() — the in-body escape hatch, straight from a helper method deep in the call chain — the moment one fails:

#[hooks]
impl Errors {
    #[hook(0, on = [Payment])]
    fn main(&self) -> HookResult {
        if otxn_field_typed(sfAccount).is_err() {
            RejectReason::BadAccountField.rollback();
        }

        match otxn_field_u64(sfSourceTag) {
            Ok(tag) if tag == u64::from(BLOCKED_SOURCE_TAG) => {
                RejectReason::BlockedSourceTag.rollback()
            }
            _ => {}
        }

        let drops = match otxn_field_typed(sfAmount) {
            Ok(AmountBytes::Native(n)) => u64::from_be_bytes(n.0) & !NATIVE_AMOUNT_FLAG_BITS,
            Ok(AmountBytes::Iou(_)) | Err(_) => RejectReason::NotNativeAmount.rollback(),
        };

        if drops > MAX_DROPS {
            RejectReason::AmountTooLarge.rollback();
        }

        Ok(Accept::from_code(0))
    }
}

Because RejectReason::rollback returns !, each match/if arm that calls it type-checks against whatever the other arms return — no placeholder value needed anywhere in the chain, and the final Ok(Accept::from_code(0)) only ever runs once every check has passed.

How the codes surface on-ledger

CodeReasonMessage
0(via Ok(Accept::from_code(0)))every check passed
-101BadAccountFielderrors: could not read otxn Account
-102BlockedSourceTagerrors: blocked SourceTag
-103NotNativeAmounterrors: unsupported (non-native) Amount
-104AmountTooLargeerrors: amount exceeds policy limit

This example deliberately chose codes in -101..=-104 — well outside the Hook API’s own -1..=-45/-10024 range — so an application-defined HookReturnCode is unambiguous at a glance against a HookError that leaked through instead. That’s not a hard requirement, just good hygiene: pick a range for your own codes and stay out of the Hook API’s.

exit_on_err!: converting a Result at the boundary

Real hook logic is often broken into small helper functions returning Result<T, YourErrorEnum>, with the conversion to rollback! happening only once, at the point the hook actually needs to exit. exit_on_err! is that conversion point:

use rshooks::{exit_on_err, hook_errors};

hook_errors! {
    /// Firewall error codes.
    pub enum FirewallError {
        /// The sender is on the blacklist.
        BlockedAccount = 1,
    }
}

fn check(blocked: bool) -> Result<u32, FirewallError> {
    if blocked {
        Err(FirewallError::BlockedAccount)
    } else {
        Ok(42)
    }
}

let value = exit_on_err!(b"firewall: blocked", check(false));
assert_eq!(value, 42);

exit_on_err!(msg, result) expands to a match: Ok(value) evaluates to value, and Err(err) calls rollback!(msg, err) — which, on the real wasm host, never returns. E needs only Into<i64>, which every hook_errors! enum provides automatically (a plain i64 error works too, via the reflexive From<i64> for i64). This is the same “convert at the boundary” shape accept!/rollback! already use for their own code argument — ordinary helper functions stay in Result-land, and only the call site that actually needs to end the hook touches rollback! directly. It’s a spelling for reaching the same escape hatch accept!/rollback! provide, not a separate mechanism from ?-propagation into HookResult — pick whichever reads better at a given call site.

Where to go next

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 past body, into the latch — the compiled loop opcode is then followed by body, 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-optimize skips wasm-opt entirely. If the same loop now passes, the failure was case 1 (wasm-opt block-wrapping) — LLVM’s own output was already guard-first. If it still fails, it’s case 2 (rotation).
  • wasm-tools print <entry>.wasm on 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 the loop opcode (the duplicated preheader copy) and again partway through the loop’s body, at its latch — is rotation (case 2). A single block opener immediately after loop, with the guard as the block’s first instruction, is the wasm-opt shape (case 1) — already fixed by the time rshooks build reports anything, so this is only visible with --no-optimize off 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 a bcmp-style byte-compare loop. (The protocol newtypes in rshooks::types — AccountId, Hash, Keylet, and the rest — are the exception: their PartialEq is hand-written to call the matching buf_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 large memcpy-shaped copy, lowers to a memset/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 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 the full build/clean/check flag reference.

Tracing and Debugging

Hooks have no debugger and no stdout. The Hook API’s trace* family of host calls is the only window into what a hook is doing while it runs, and rshooks wraps them in three macros — trace!, trace_num!, and trace_float! — along with two small compile-time helper macros, pad! and pad_left!, that are unrelated to tracing but live alongside these macros in rshooks’s macros module. This page covers all five, and where tracing output actually goes.

trace!, trace_num!, trace_float!

trace!(msg);              // message only
trace!(msg, data);        // message + a byte slice
trace_num!(msg, number);  // message + an i64
trace_float!(msg, value); // message + an XFL value

msg is a raw byte slice, as with accept!/rollback! — there’s no format! available in a no_std hook, so build any dynamic content as bytes ahead of time rather than reaching for string formatting. trace!’s two-argument form’s data is rendered as raw bytes by the underlying host call, not hex — see the hex-dump note below for the raw-layer alternative if you need that.

Underneath the macros, rshooks::api::trace exposes the same three operations as plain functions you can call directly regardless of feature flags:

pub fn trace(msg: &[u8], data: &[u8], as_hex: bool) -> Result<i64>;
pub fn trace_num(msg: &[u8], number: i64) -> Result<i64>;
pub fn trace_float(msg: &[u8], value: XFL) -> Result<i64>;

trace’s as_hex parameter is the hex-dump switch the macros don’t expose: passed as true, data is rendered as a hex dump by the host instead of raw bytes — useful for inspecting something like a raw AccountId or transaction hash byte-for-byte. Reach for rshooks::api::trace::trace(msg, data, true) directly when you need that; the trace! macro’s (msg, data) form always passes as_hex: false.

Where output goes

Trace output is not visible in a transaction’s result or metadata — it goes to the Hook host’s own debug/trace log, visible when the node it runs on is started with trace logging enabled. This makes tracing purely a development and debugging tool: useful while writing and testing a hook against a local or test node, but not something a production hook’s correctness should ever depend on, and not something an external observer (an indexer, a wallet) can see. If a value needs to be visible to the outside world, it belongs in the accept!/rollback! message or code (see Accept, Rollback, and Errors), not a trace call.

Tracing costs bytes and instructions

Every trace call — like every other host call — costs execution instructions and adds to a hook’s worst-case instruction count, and the msg/data bytes it’s given are real bytes the hook has to construct. That’s exactly why the trace!/trace_num!/trace_float! macros are feature-gated: they compile to nothing at all unless rshooks’s own trace feature is enabled.

rshooks = { version = "...", features = ["trace"] }

No feature re-declaration is needed in the hook crate itself beyond enabling it on the rshooks dependency — the macros expand to calls into rshooks::api::trace::__macro_support’s shim functions, which are always present but only forward to the real host call when the feature is on; otherwise they’re a no-op that still consumes (and thus doesn’t warn about) their arguments. This lets a hook crate leave trace!/trace_num!/ trace_float! calls in its source permanently, toggling them on only for a debug build, without editing the call sites at all. The plain functions in rshooks::api::trace (trace, trace_num, trace_float) are unconditional — call those directly instead if you want tracing regardless of feature flags.

pad! and pad_left!

These two macros are unrelated to tracing, but worth knowing alongside it since real hook code often builds byte buffers to hand to trace! (or accept!/rollback!) by hand. Both zero-pad a constant byte string to a fixed-size array, entirely at compile time — no runtime copy or zeroing loop, so no guard is needed for either:

use rshooks::pad;

let padded: [u8; 10] = pad!(b"hello");
assert_eq!(padded, [b'h', b'e', b'l', b'l', b'o', 0, 0, 0, 0, 0]);

pad! right-pads (src at the front, zero bytes at the end); pad_left! is its mirror image, left-padding instead:

use rshooks::pad_left;

let padded: [u8; 10] = pad_left!(b"hello");
assert_eq!(padded, [0, 0, 0, 0, 0, b'h', b'e', b'l', b'l', b'o']);

Both expand to an inline const block, so the source must be a constant expression, and a source longer than the destination is a compile error, never a runtime panic — the array length itself is inferred from the surrounding context (a let binding’s type, a struct field, and so on).

It’s worth noting what these are not for anymore: building a short hook- state key. A [u8; N] (with 1 <= N <= STATE_KEY_LEN) works directly as a StateKeyEncode key at its own real length — the host itself left-pads a short key internally, so a Rust hook doesn’t need to reproduce that padding locally (see Hook State). Reach for pad!/pad_left! when a fixed-size buffer genuinely needs local padding for some other reason — building an already-full-width constant on purpose, or padding a byte string for a use unrelated to hook-state keys.

Where to go next

Reading the Originating Transaction

Every Hook invocation is triggered by a transaction — the originating transaction, or “otxn” for short. Almost every hook needs to inspect it: who sent it, what type it is, how much it moves, what fields it carries. rshooks exposes this through the api::otxn module, re-exported by the prelude. This page covers dispatching on the transaction’s type, the family of functions for reading its fields, and reading its transaction ID. Hook and originating-transaction parameters (hook_param/otxn_param) are a related but separate mechanism, covered in Hook and Transaction Parameters.

Dispatching on transaction type

otxn_type() returns a TxType, a typed, exhaustive-by-construction enum decoded from the raw tt* code the Hook API returns:

use rshooks::prelude::*;

match otxn_type() {
    TxType::Payment => { /* ... */ }
    TxType::TrustSet => { /* ... */ }
    TxType::Invoke => { /* ... */ }
    other => {
        // `TxType::Unknown(code)` covers any `tt*` code this crate does not
        // (yet) know a name for — forward-compatible with new transaction
        // types without a hard compile error.
        let _ = other;
    }
}

Every known transaction type (ttPAYMENT, ttESCROW_CREATE, ttTRUST_SET, ttHOOK_SET, …) has its own variant; TxType::Unknown(u16) is the catch-all for a code this crate doesn’t model yet. TxType also gives back its raw code via .code() when you need it. Most hooks gate their logic on their entry’s declared on/on_incoming+on_outgoing list already (see Per-Hook Attributes), so otxn_type is typically used for a final sanity check or to branch between a handful of expected types within one hook.

Reading a field: the typed path is the default

rshooks generates a table of typed field constants in the sfield module — one SField<T> per sfXxx code, each carrying the Rust type that field’s value decodes as. sfAccount is an SField<AccountId>, sfSequence an SField<u32>, sfAmount an SField<Amount>. otxn_field_typed reads a field using exactly that pairing — no turbofish, no separate decode step, and no way to accidentally decode the wrong field as the wrong type (the constant itself pins down the return type):

use rshooks::prelude::*;

let sender: Result<AccountId> = otxn_field_typed(sfAccount);
let sequence: Result<u32> = otxn_field_typed(sfSequence);

This is the default way to read any field the generated sfield table gives a value type to. Narrow integers (u8/u16/u32) go through the host’s as-int64 mode; u64 and every fixed-byte type (Hash, AccountId, CurrencyCode) read their exact wire bytes; Amount and Issue are classified by length and come back as AmountBytes/IssueData rather than a single scalar, since their wire encoding is one of two shapes:

use rshooks::prelude::*;

match otxn_field_typed(sfAmount) {
    Ok(AmountBytes::Native(drops)) => {
        // `drops.0` is the raw 8-byte big-endian wire encoding — see
        // "Decoding a raw field" below for why it's `from_be_bytes`, not
        // this crate's `FromBytes` trait.
        let _ = drops;
    }
    Ok(AmountBytes::Iou(_)) => { /* an IOU amount */ }
    Err(_) => { /* field missing or unreadable */ }
}

examples/03_hook-params uses exactly this pattern to reject any non-native Amount:

let drops = match otxn_field_typed(sfAmount) {
    Ok(AmountBytes::Native(n)) => u64::from_be_bytes(n.0) & !NATIVE_AMOUNT_FLAG_BITS,
    Ok(AmountBytes::Iou(_)) | Err(_) => rollback!(
        b"hook-params: unsupported (non-native) Amount",
        HookParamsError::UnsupportedAmount
    ),
};

(The top two bits of a serialized native amount are format flags, not part of the drops value, hence the mask.)

Not every field has a modeled value type — Blob, STObject, STArray, and a handful of others map to Opaque, which supports navigation but no single scalar value(). For those, or when you just want the raw wire bytes, reach for the two escape hatches below.

The raw escapes: otxn_field and otxn_field_exact

otxn_field reads a field into a caller-provided buffer and returns the number of bytes written — the least opinionated option, usable for any field regardless of whether this crate models a typed read for it:

use rshooks::prelude::*;

let mut buf = [0u8; 20];
let written = otxn_field(&mut buf, sfAccount)?;

otxn_field_exact is the fixed-length middle ground: it requires the field to be exactly T’s length (any FixedRead type — a rshooks::types newtype, or a raw [u8; N]), with T inferred from context rather than a turbofish:

use rshooks::prelude::*;

let sender: AccountId = otxn_field_exact(sfAccount)?;
let raw_sequence: [u8; 4] = otxn_field_exact(sfSequence)?;

There’s also otxn_field_u64, the as-int64 escape hatch for a field of at most 8 bytes with the top bit clear — the same convention otxn_field_typed’s narrow-integer impls use internally.

Decoding a raw field: from_be_bytes, not FromBytes

This is the one trap worth calling out explicitly. The bytes otxn_field/otxn_field_exact hand back are Xahau Binary — the protocol’s own big-endian wire format — never this crate’s little-endian FromBytes trait, which is the convention for hook-private data (state and parameter values this crate’s own typed layer wrote, covered in Hook State and Typed Data with Derives). A numeric protocol field read through a raw escape needs an explicit u64::from_be_bytes(...) at the call site — exactly what the hook-params example above does for sfAmount’s native drops value. otxn_field_typed already does this decoding itself for every field it models, so a field with a modeled type never needs the idiom at all.

The transaction ID

otxn_id writes the originating transaction’s hash into a caller buffer; otxn_id_buf is the fixed-size convenience twin that returns a Hash directly:

use rshooks::prelude::*;

let id: Hash = otxn_id_buf(0)?;

flags = 0 prefers the emit-failure transaction ID where applicable; other flag values pass through verbatim to the host.

Burden and generation

For an emitted transaction (one a hook itself created via emit, see Emitting Transactions), otxn_burden and otxn_generation report the emit chain’s burden and depth; for a normal, directly-submitted transaction they read back 1 and 0 respectively.

A worked example: the firewall pattern

examples/05_firewall is a compact illustration of the typed field path end to end — read the sender as an AccountId, compare it against a configured blocklist, and roll back on a match:

#[hooks]
impl Firewall {
    #[hook(0, on = [Payment])]
    fn main(&self) -> HookResult {
        let Ok(sender) = otxn_field_typed(sfAccount) else {
            rollback!(
                b"firewall: could not read otxn sender",
                FirewallError::CouldNotReadSender
            )
        };

        let Some(blocked) = blocked_account() else {
            accept!()
        };

        // `AccountId`'s `==` is loop-free too, but spelling this as
        // `buf_eq_20` makes the loop-free mechanism explicit.
        if buf_eq_20(&sender, &blocked) {
            rollback!(b"firewall: blocked account", FirewallError::BlockedAccount);
        }

        accept!()
    }
}

otxn_field_typed(sfAccount) reads back an AccountId directly — no turbofish, no manual length check. Note the comparison: AccountId’s == is itself loop-free (it delegates to buf_eq_20 internally), so sender == blocked would work here too; the example spells it as buf_eq_20(&sender, &blocked) to make that loop-free-by-construction property explicit at the call site rather than relying on a type’s PartialEq impl. On a bare [u8; 20] buffer (not one of these typed newtypes), == would compile to a byte-compare loop the Hook API’s guard checker would need a guard! for (see Guards and Loops); buf_eq_20 is loop-free by construction (every byte index is a source-level literal) and sidesteps the issue entirely regardless of type. blocked_account() reads a Hook parameter declared on the Firewall struct — see Hook and Transaction Parameters.

Reading several fields of a known format: views

Once the transaction’s type is established (by otxn_type(), or by the hook’s own on/on_incoming+on_outgoing list), rshooks::views::tx gives named accessors for every field the protocol declares on that format instead of one otxn_field_typed(sfXxx) call per field:

use rshooks::views::tx;

let Ok(payment) = tx::Payment::otxn() else {
    rollback!(b"not a Payment", ...)
};
let dest = payment.destination()?;   // sfDestination
let amount = payment.amount()?;      // sfAmount, as AmountBytes

Payment::otxn() itself is one otxn_type call plus an integer compare, and every accessor after it is exactly the otxn_field_typed call it would otherwise take by hand — the view adds named fields, not a different read path. See Typed Views for the full picture, including which formats need the active-amendments/all-amendments feature.

Nested fields: slots

Everything on this page reads a top-level field of the originating transaction directly. A field nested inside an object or array (an entry in a Memos array, a field of a SignerListSet signer entry, …) needs slot-based access instead — load the transaction into a slot with otxn_slot and navigate from there. See Slots and Ledger Objects for the full slot API.

Hook State

A Hook’s persistent storage is a flat key-value store scoped to the account it’s installed on (and, for a foreign read, another account’s namespace too). rshooks gives you three tiers of access to it, from a raw buffer read all the way up to a struct field with generated typed accessor methods. This page walks through all three, plus reading another account’s state. If you haven’t read Typed Data with Derives yet, the #[derive(HookKey)]/#[derive(HookData)] derives it covers are what the higher tiers here are built on.

The state model: 32-byte keys, host-side left-padding

Every state entry is addressed by a key of up to 32 bytes. The Hook API accepts a key from 1 to 32 bytes and left-pads a shorter key internally to its own fixed-width storage slot — the same idiom a C hook uses when it calls state(&v, 8, "RR", 2) with a 2-byte literal key. rshooks mirrors this at every layer: a short key is sent to the host at its own real, unpadded length, never locally zero-padded to 32 bytes. Values, by contrast, are read and written as plain bytes with no implied structure — interpreting them is entirely up to the layer you’re using.

Tier 1: the loose, single-value API

rshooks::api::state (re-exported by the prelude) is the lowest-level typed convenience over the raw state/state_set host calls: a family of small functions for exactly the primitive cases, each taking a raw &[u8]-like key with no key-type story of its own.

use rshooks::prelude::*;

let mut buf = [0u8; 32];
let key = [0u8; 32];
let written = state(&mut buf, &key)?;

state_set(&buf[..written], &key)?;

For the common primitive shapes there are dedicated helpers — state_u32/state_set_u32, state_i64/state_set_i64, state_xfl/state_set_xfl, and their state_update_* read-modify-write counterparts — all little-endian via state_exact under the hood. The one outlier is state_u64/state_update_u64, which use the host’s as-int64 mode and read/write big-endian — intended for an entry whose bytes originated from Xahau Binary itself (a protocol-mirroring value, or interop with a C hook), not one this crate’s own typed layer wrote. For a little-endian u64 written by the typed layer, use state_u64_le instead. state_exact::<T> is the general fixed-length escape hatch this tier is built on, identical in spirit to otxn_field_exact (see Reading the Originating Transaction): T must be exactly the right length, inferred from context, no turbofish.

Reach for this tier for a one-off primitive read/write with no reuse value, or as the escape hatch Hook Chains covers for a hook whose typed-accessor call-site density has outgrown its nesting budget. For a hook with more than a couple of distinct state entries, the next two tiers pay off quickly.

Tier 2: state_keys! — a typed key enum, independent value type

crate::state’s state_get/state_set_loose/state_update_loose work for any type implementing ToBytes/FromBytes — not just the primitives Tier 1 hard-codes — paired with a state_keys!-declared enum for the key side:

use rshooks::prelude::*;
use rshooks::state_keys;

state_keys! {
    /// This hook's persistent data.
    enum DataKey {
        /// A running counter.
        Counter,
        /// A per-owner balance, keyed by the owner's account.
        Balance(AccountId),
    }
}

let count: Option<u64> = state_get(&DataKey::Counter)?;
state_set_loose(&DataKey::Counter, &1u64)?;

A unit variant (Counter) encodes to just its 1-byte discriminant, no padding at all. A tuple variant (Balance(AccountId)) carries exactly one ToBytes payload, encoded at runtime as “discriminant byte + payload,” again with no trailing padding — the real length sent to the host is 1 + Payload::MAX_LEN. Declaration order matters: the macro assigns each variant a sequential u8 discriminant, so inserting or reordering a variant changes every later variant’s encoded key (and thus which on-chain slot it addresses).

state_get/state_set_loose/state_update_loose still take the key and the value type as independent generic parameters, though — nothing stops calling state_get::<SomeOtherType>(&DataKey::Counter) for a pairing that was never intended, as long as SomeOtherType: FromBytes (true of nearly every fixed-size type this crate provides). That’s exactly the gap Tier 3 closes.

For a value type over Tier 1/2’s 32-byte cap, a state_keys! key still pays off against the raw state/state_set calls directly via StateKeyEncode::with_key_bytes — see Keylets’s worked example.

Tier 3: #[state(...)] struct fields — a key permanently paired with its value type

A field on a #[hooks] struct (see Anatomy of a Hook) can declare a hook-state entity: a key bound to exactly one value type, via a State<V> field carrying a #[state(...)] attribute. There is no second, independently-chosen value type left for a mismatch to hide in — passing the wrong value where this field’s V is expected is a compile error.

The attribute has exactly two forms, because the key’s shape is carried by an ordinary Rust type (S::KeyArgs, resolved through the field generated for it) rather than needing its own bespoke struct declaration:

formkey shapeexample
key = <expr>fully fixed — a constant expression whose type implements key-encoding (a byte-string literal works directly)#[state(key = b"RR")]
key_by = <TypePath>keyed — constructed per call site from any type already implementing StateKeyEncode (a #[derive(HookKey)] struct, a state_keys! enum, or a primitive array type)#[state(key_by = DepositKey)]

key = ...: a fixed key

use rshooks::*;

#[hooks]
pub struct StateCounter {
    /// Persistent invocation counter, stored at the fixed key `"counter"`.
    #[state(key = b"counter")]
    counter: State<u64>,
}

declares a field named counter, addressed by the fixed key b"counter", holding a u64. Because the struct has a named field, the macro also generates a static value named after the struct (StateCounter, same name, different namespace — see Anatomy of a Hook). An entry (or a helper inside the same #[hooks] impl) declares &self to receive that static and calls the field’s accessors as self.state.counter.get(); code outside the impl reaches the identical static by the struct’s own name instead: StateCounter.state.counter.get(). key also accepts a const reference to something more structured than a literal, as long as it encodes:

const ENABLED_KEY: StateKey = StateKey(pad!(b"enabled"));

#[hooks]
pub struct StateForeign {
    #[state(key = &ENABLED_KEY)]
    enabled: State<[u8; 1]>,
}

key_by = ...: a key constructed per call site

Use this when the key varies at runtime — keyed by the calling account, for example:

#[derive(HookKey, Clone, Copy)]
struct DepositKey {
    tag: u8,
    owner: AccountId,
}

#[hooks]
pub struct TypedData {
    #[state(key_by = DepositKey)]
    deposits: State<DepositValue>,
}

deposits on its own is the field, not yet addressed to a specific entry — call .at(args) to bind the key’s runtime arguments and get a handle with the same accessor set. Inside the #[hooks] impl, an entry reaches it as self.state.deposits:

#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
    let deposit = self.state.deposits.at(DepositKey { tag: DEPOSIT_TAG, owner });
    let current = match deposit.get() {
        Ok(existing) => existing.unwrap_or(EMPTY_DEPOSIT),
        Err(_) => rollback!(
            b"typed-data: state read failed",
            TypedDataError::StateReadFailed
        ),
    };
    // ...
    if deposit.set(&next).is_err() {
        rollback!(
            b"typed-data: state_set failed",
            TypedDataError::StateSetFailed
        );
    }
}

.get()/.set() return rshooks::error::Result (HookError); there is no From<HookError> for Rollback, so ? on those calls inside a -> HookResult entry does not compile. Convert with match/rollback! as above, or see Accept, Rollback, and Errors for the ? + hook_errors! + .map_err(|_| MyError::…) pattern.

DepositKey here is any type that already implements StateKeyEncode — most often a #[derive(HookKey)] struct (see Typed Data with Derives) or a state_keys! enum, exactly the same key types Tier 2 uses, so a key shape you’ve already declared for Tier 2 slots directly into key_by with no redeclaration.

The generated accessors

Every #[state(...)] field — used directly for key = ..., or through .at(args) for key_by = ... — gets the same six methods:

methodsignaturebehavior
.get()Result<Option<V>>Ok(None) for “no entry”; a genuine decode failure or host error is Err, never confused with absence.
.set(&value)Result<usize>Writes value, returning the byte count written.
.update(f)Result<usize> where f: FnOnce(Option<V>) -> VReads (Option<V>, same absence handling as .get()), applies f, writes the result — one round trip.
.delete()Result<()>See “Deleting an entry” below.
.get_foreign(ns, acct)Result<Option<V>>Same as .get(), but on another namespace/account — see “Foreign state” below.
.set_foreign(&value, ns, acct)Result<usize>Same as .set(), foreign-addressed.

These are thin, #[inline(always)] forwards to the same underlying functions Tier 1/2 call (state_get, state_set_loose, and so on) — the struct field’s job is purely to fix the key and value type together at the declaration site, not to introduce a new code path.

The counter walkthrough

examples/02_state-counter is the smallest complete tutorial for the typed layer:

#[hooks]
pub struct StateCounter {
    #[state(key = b"counter")]
    counter: State<u64>,
}

#[hooks]
impl StateCounter {
    #[hook(0, on = [Invoke])]
    fn main(&self) -> HookResult {
        let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);

        let next = count.wrapping_add(1);
        if self.state.counter.set(&next).is_err() {
            rollback!(
                b"state-counter: state_set failed",
                StateCounterError::StateSetFailed
            );
        }

        Ok(Accept::new(b"state-counter: incremented", next as i64))
    }
}

main declares a &self receiver, so self.state.counter.get() returns Result<Option<u64>>: Ok(None) means “no entry yet” (see below), so the double unwrap_or handles both “never written” and “an unexpected read error” the same way, defaulting to zero either way.

Ok(None) means “no entry” — never a special-cased error

Every typed read here maps “no entry for this key” to Ok(None), the same shape as HashMap::get — ordinary, not exceptional. Every other error, including a present-but-undersized entry that fails to decode as T, still comes back as Err, so a genuine decode failure is never mistaken for “nothing was ever stored here.”

Deleting an entry

The Hook API has no dedicated “delete” call — an entry is deleted by writing zero bytes to it, which also refunds the owner reserve it was holding. .delete() is the explicit spelling for that, independent of any value type — deliberately not reachable by pairing a key with a value type that happens to encode to nothing, which would spell “delete” as an accident of the value type rather than an intent at the call site. examples/12_typed-data deletes a depositor’s record on full withdrawal for exactly this reason (releasing the reserve, rather than leaving a zeroed entry behind):

if deposit.delete().is_err() {
    rollback!(
        b"typed-data: state_set failed",
        TypedDataError::StateSetFailed
    );
}

Foreign state: reading another account’s entries

.get_foreign(ns, acct)/.set_foreign(&value, ns, acct) (and the raw-tier state_foreign/state_foreign_get/state_foreign_get_typed free functions they forward to) read or write a state entry belonging to another account, or another namespace on this hook’s own account. namespace/account are Option<&[u8]>, defaulting to “this hook’s own” when passed None.

examples/09_state-foreign reads a flag from a target account configured via a Hook parameter:

#[hooks]
pub struct StateForeign {
    /// The target account whose flag this hook reads (`ACCT`).
    #[hook_param(name = b"ACCT", required)]
    acct: HookParam<AccountId>,

    /// The target account's flag, read via `get_foreign` under
    /// [`ENABLED_KEY`] in this hook's own namespace.
    #[state(key = &ENABLED_KEY)]
    enabled: State<[u8; 1]>,
}

#[hooks]
impl StateForeign {
    #[hook(0, on = [Invoke])]
    fn main(&self) -> HookResult {
        let Ok(target) = self.hook_param.acct.get_required() else {
            rollback!(
                b"state-foreign: ACCT parameter not configured",
                StateForeignError::AcctNotConfigured
            )
        };

        let flag = match self
            .state
            .enabled
            .get_foreign(None, Some(target.as_ref()))
        {
            Ok(Some(v)) => v,
            Ok(None) => rollback!(
                b"state-foreign: not configured on target account",
                StateForeignError::NotConfiguredOnTarget
            ),
            Err(_) => rollback!(
                b"state-foreign: state_foreign read failed",
                StateForeignError::ReadFailed
            ),
        };

        if flag[0] == 0 {
            rollback!(
                b"state-foreign: target account's flag is off",
                StateForeignError::FlagOff
            );
        }

        Ok(Accept::from_code(0))
    }
}

Passing namespace = None and account = Some(target.as_ref()) reads the entry keyed ENABLED_KEY in this hook’s own namespace, but on target’s account — the shape for “the same hook code, installed on account A and account B, where A wants to read a flag B’s copy of the hook maintains about itself.” get_required() (covered in Hook and Transaction Parameters) is this example’s way of turning a missing ACCT parameter into an immediate, distinct rollback reason, distinct from ACCT being present but the target having no matching state entry (Ok(None) from get_foreign).

State interface (typed on-ledger schema)

This section’s surface requires the unstable-state-interface feature: rshooks = { version = "…", features = ["unstable-state-interface"] }. unstable-* features track draft specs and are exempt from semver — breaking changes may land in a minor release while the spec is a draft.

Every tier above pairs a key with a value type entirely inside rshooks — nothing about that pairing is visible on-ledger. The Hook State Interface is a different, protocol-level convention: a HookParameters convention that exposes a Hook’s state layout as a machine-readable, typed key/value schema, so an external, schema-aware client can decode a Hook’s state without knowing anything about the hook’s own source. The normative rules live in docs/STATE_INTERFACE_DESIGN.md; this section covers the day-to-day surface built on it.

#[hooks]
pub struct Treasury {
    #[state_interface(id = 0, key(account: AccountId, token: u32),
                      value(amount: u64, updated: u32))]
    balances: State<Balance>,

    #[state_interface(id = 1, value(paused: u8))]
    paused: State<Config>,
}

(the design doc’s own worked example, also examples/20_state-interface.) #[state_interface(..)] lives in the same State namespace as an ordinary #[state(..)] field — self.state.balances/self.state.paused — and the field still works through the same .at(..)/.get()/.set()/.update()/ .delete() accessors this whole page covers. What’s different: Balance/ Config are not written by hand — the macro generates them from the value(..) schema — and the struct’s #[hooks] carrier gains a machine-readable description of both entries’ on-ledger layout, which rshooks-build turns into HookParameters declaration entries in sethook.template.json (see Metadata and the SetHook Template).

Declaration grammar

  • id = <0..=255> — the State ID, required, unique across every #[state_interface] field on the struct (identifiers, not positional indexes — contiguity isn’t required).
  • key(name: Type, ..) — ordered key fields, optional; omitted means a singleton (no key at all, direct .get()/.set() with no .at(..)).
  • value(name: Type, ..) — ordered value fields, required, at least one.
  • The field’s own type must be State<VName>, where VName is a bare identifier the macro generates a struct from — not an existing type.
  • Every field name is [A-Za-z][A-Za-z0-9]*, 1 to 16 bytes (no _ — same rule the signature parameter interface’s argument names follow).

Supported types

Version 0 of the interface supports only fixed-width types — the rows the signature-parameter interface’s own table draws from, minus the variable-width ones (AmountBytes, Blob<N>, IssueBytes) that interface supports and this one does not:

Rust typetype codewidth
u80x10 (STI_UINT8)1
u160x01 (STI_UINT16)2
u320x02 (STI_UINT32)4
u640x03 (STI_UINT64)8
[u8; 16]0x04 (STI_UINT128)16
[u8; 32] / Hash0x05 (STI_UINT256)32
AccountId0x08 (STI_ACCOUNT)20
[u8; 20]0x11 (STI_UINT160)20
CurrencyCode0x1A (STI_CURRENCY)20
XFL0x80 (XFL)8

XFL’s value is the Hook API’s int64_t XFL bit pattern, big-endian — XFL::raw_bits() — decoded via XFL::from_raw_bits with no validity check; the type codes themselves come from XAS-010d (Hook Type Codes), which both this interface and the Hook Parameter Signature Interface reference for their type codes. XFL is fixed-width, so it is usable as a key field as well as a value field.

Every key/value field’s type is pinned against alias drift by a monomorphized const assert on rshooks::si::SiFieldType::TYPE_BYTE (token-level type checks are alias-forgeable) — the same defense SigParamType::TYPE_BYTE provides for signature parameters.

The generated key and value bytes

Unlike the rest of this page, a #[state_interface] key is exactly 32 bytes, always: StateID || Encode(K0) || Encode(K1) || .. || zero padding — the interface fixes the physical key layout as part of its wire contract, so rshooks builds the full 32-byte key locally and sends all 32 bytes, rather than relying on the host’s own left-pad convention this page’s other tiers use. A singleton’s key is StateID || 31 zero bytes, promoted to a 'static compile-time constant the same way a literal #[state(key = b"...")] is.

The generated value struct’s fields are encoded big-endian (not this crate’s ordinary little-endian HookData convention) and concatenated directly — no field IDs, separators, or length prefixes — because a state interface value is protocol-facing, schema-aware-client-readable data, the same rationale signature parameters’ big-endian convention follows. There’s no compile-time cap on a value schema’s total encoded length (unlike the key, or the declaration’s own HookParameterName/HookParameterValue bounds) — it must fit the installing account’s own Hook State data size limit, an account-dependent write-time constraint the interface itself leaves for the host to enforce.

The design doc’s own worked spec vector, for account = 4B4E9C06F24296074F7BC48F92A97916C6DC5EA9, token = 42, amount = 1000, updated = 12345:

  • HookStateKey: 004B4E9C06F24296074F7BC48F92A97916C6DC5EA90000002A00000000000000
  • HookStateData: 00000000000003E800003039

See crates/rshooks-testenv/tests/state_interface.rs for this vector driven end-to-end through TestEnv::invoke, asserted against the raw stored bytes.

Declarations are advisory metadata

Nothing about #[state_interface] changes what the host enforces: a declared field still goes through the same accessors as an ordinary #[state(..)] field, which the host accepts unconditionally. The wire format only shapes what a schema-aware client can expect a conforming hook to have written — nothing stops a hook from advertising a schema and then writing something else, same as any machine-readable interface layered on top of an otherwise-untyped protocol.

Where to go next

Every typed value type on this page — the u64 in the counter example, the DepositValue struct, AccountId as a DepositKey field — is either a primitive rshooks already implements ToBytes/FromBytes for, or a struct built with #[derive(HookKey)]/#[derive(HookData)]. See Typed Data with Derives for how those derives work, their exact byte layout, and why they cost nothing over hand-packing. See Hook Chains for how a #[state(...)] field declared once is shared across every Hook entry in the same chain.

Hook and Transaction Parameters

Hooks read configuration from two distinct sources, both called “parameters” but attached at very different times: hook parameters, set once when the hook is installed (via SetHook), and originating transaction parameters, attached fresh by whoever submits the triggering transaction. This page covers both — the loose byte-buffer accessors, the #[hook_param(...)]/#[otxn_param(...)] struct-field attributes that pair a name with a value type, and composite (struct-shaped) names via #[derive(ParamName)].

Two sources, one shape

Hook parameterOtxn parameter
set bythe hook’s operator, at SetHook timewhoever submits the triggering transaction
read withhook_param/hook_param_exact/hook_param_typedotxn_param/otxn_param_exact/otxn_param_typed
struct field#[hook_param(...)] field: HookParam<V>#[otxn_param(...)] field: OtxnParam<V>
typical useoperator-controlled configuration (a minimum amount, a blocklist entry, a pause switch)per-invocation instructions the caller supplies

Both mechanisms are read-only from the reading hook’s own perspective — a hook parameter is set by whoever installs the hook, not written by the hook itself at runtime (hook_param_set exists, but it writes a different hook’s parameter, taking a raw &[u8], not a typed value — out of scope for this page). Because of that, both sides share the exact same field attribute grammar and accessor shape; only the underlying host call differs.

The loose accessors

hook_param/otxn_param read a named parameter into a caller-provided buffer, mirroring otxn_field’s shape (see Reading the Originating Transaction):

use rshooks::prelude::*;

let mut buf = [0u8; 32];
let written = hook_param(&mut buf, b"CFG")?;

hook_param_exact/otxn_param_exact require the parameter to be exactly T’s length (any FixedRead type), with T inferred from context, not a turbofish. A typical use is a compiled-in default when the operator hasn’t configured a minimum:

const THRESH_PARAM: &[u8] = b"THRESH";
const DEFAULT_THRESHOLD: u64 = 1_000_000;

fn threshold() -> u64 {
    hook_param_exact(THRESH_PARAM)
        .map(u64::from_be_bytes)
        .unwrap_or(DEFAULT_THRESHOLD)
}

hook_param_exact’s return type is inferred as [u8; 8] from the .map(u64::from_be_bytes) call — no turbofish needed. Note the from_be_bytes, not this crate’s FromBytes trait: a raw parameter byte buffer is whatever the caller who set it chose to write, and this tier leaves that byte convention entirely up to whatever wrote it — here chosen (by this snippet, not the crate) to match Xahau Binary’s big-endian numeric encoding, the same convention Reading the Originating Transaction describes for raw protocol fields. .unwrap_or(DEFAULT_THRESHOLD) collapses “not configured at all” and “configured with a value of the wrong size” into the same fallback, without treating a malformed parameter as a hard error. This tier needs no field declaration on the #[hooks] struct at all — reach for it for a one-off read, or as the escape hatch Hook Chains covers when accessor density at one call site outgrows the nesting budget. The declared-field tier below decodes every value through this crate’s FromBytes trait instead, so its byte convention is always little-endian, fixed by the crate rather than chosen per call site — see examples/03_hook-params’s MIN parameter for a worked example.

Struct fields: #[hook_param(...)] / #[otxn_param(...)]

hook_param_exact/otxn_param_exact take the name and the value type T as two independent arguments — nothing stops calling otxn_param_exact::<WrongType>(b"T") for a name/type combination that was never intended, as long as WrongType: FixedRead (true of nearly every fixed-size type this crate provides, including some other parameter’s value type). A field on the #[hooks] struct closes that gap by declaring a name permanently paired with one value type:

#[hooks]
pub struct Firewall {
    /// The blocked account, configured via the `BL` Hook parameter.
    #[hook_param(name = b"BL")]
    blocked: HookParam<AccountId>,
}

The attribute grammar is the same for both #[hook_param(...)] and #[otxn_param(...)] — only the field’s type (HookParam<V> vs. OtxnParam<V>) picks which host call the generated accessors read through:

argumentmeaning
name = <byte-string literal>a fixed, literal name — free at runtime, since the wire encoding is the literal’s own bytes
name_by = <TypePath>a composite name, constructed per call site — see “Composite names” below
requiredadds .get_required() (mutually exclusive with default)
default = <expr>adds .get_or_default(), falling back to <expr> (mutually exclusive with required)

The accessors, and what “absent” actually means

Every field gets .get() -> Result<Option<V>> unconditionally; required and default each add one more method, and are mutually exclusive because they answer the same question — “what happens when this parameter isn’t set?” — two different ways:

methodavailablebehavior on absencebehavior on a present-but-malformed value
.get()alwaysOk(None)Err
.get_or_default()with default = <expr>Ok(<expr>)Err — not silently replaced by the default
.get_required()with requiredErr (a dedicated “missing” error, distinct from a decode error)Err

The load-bearing rule, worth stating precisely: “absent” is decided before any decoding happens, from the host API’s own “doesn’t exist” signal — never inferred after the fact from a decode failure. A parameter that is set, but to the wrong number of bytes for V, is a decode failure, and .get_or_default() reports that as Err rather than quietly substituting the default. If you want “any read failure at all, absence or malformed, falls back to the same value,” write that explicitly at the call site instead, the same way examples/05_firewall does:

fn blocked_account() -> Option<AccountId> {
    Firewall.hook_param.blocked.get().ok().flatten()
}

.ok() turns Err into None, and .flatten() collapses the resulting Option<Option<AccountId>> down to one level — deliberately masking a decode failure the same way as absence, rather than treating a malformed BL value as anything worth telling the caller apart from “no blocklist configured.”

required: a required parameter

#[hooks]
pub struct StateForeign {
    /// The target account whose flag this hook reads (`ACCT`).
    #[hook_param(name = b"ACCT", required)]
    acct: HookParam<AccountId>,
}
let Ok(target) = self.hook_param.acct.get_required() else {
    rollback!(
        b"state-foreign: ACCT parameter not configured",
        StateForeignError::AcctNotConfigured
    )
};

(from examples/09_state-foreign’s &self entry.) get_required() collapses “absent” and “present but malformed” into the same Err at this call site — the hook treats both as “can’t proceed,” and the else branch rolls back either way.

default: a compiled-in fallback

#[hooks]
pub struct TypedData {
    /// Install-time configuration (`CFG`). Falls back to compiled-in
    /// defaults when absent.
    #[hook_param(name = b"CFG", default = Config { min_amount: DEFAULT_MIN_AMOUNT, lock_ledgers: DEFAULT_LOCK_LEDGERS })]
    config: HookParam<Config>,
}

(from examples/12_typed-data.) default’s expression is a runtime fallback, not baked into the deployed SetHook template as an installed value — see Per-Hook Attributes for why a HookParameters entry has to be added to the template by hand if you want a position to install with a concrete value at all. Contrast required above: the field declaration itself (default = ... vs. required) is where a parameter’s presence policy lives, not scattered across call sites — config here degrades gracefully when CFG is unconfigured, the same way examples/09_state-foreign’s acct field (above) never does.

Composite names: #[derive(ParamName)] and name_by

A Hook API parameter name isn’t always a plain literal tag — per the Hook API itself it’s a genuine variable-length key of up to 32 bytes, and (like a hook state key) can be a whole composite, struct-shaped value instead of a byte string. #[derive(ParamName)] derives ToBytes (write-only — see Typed Data with Derives) for a named-field struct used this way; name_by = <TypePath> is how a field references one:

#[derive(ParamName, Clone, Copy)]
struct AdminName {
    section: u8,
    field: u8,
}

#[hooks]
pub struct TypedData {
    /// Administrative deposit pause switch, addressed by [`AdminName`].
    /// Falls back to "not paused" when absent.
    #[hook_param(name_by = AdminName, default = PauseSwitch { paused: 0 })]
    admin_pause: HookParam<PauseSwitch>,
}

Unlike a key = .../name = ... field, which is ready to read directly, a name_by field needs its name’s runtime value bound first, via .at(...) — the same shape Hook State uses for a keyed state entry:

const ADMIN_PAUSE_NAME: AdminName = AdminName { section: 0, field: 0 };

fn deposits_paused() -> Result<bool> {
    TypedData
        .hook_param
        .admin_pause
        .at(ADMIN_PAUSE_NAME)
        .get_or_default()
        .map(|s| s.paused != 0)
}

Returning Result<bool> rather than bool keeps a malformed switch’s Err visible to the caller, which rolls back on it instead of treating it as false.

.at(args) returns a handle over that one bound name, carrying the same .get()/.get_or_default()/.get_required() accessors the base field has (gated by the same required/default declaration). AdminName encodes to 2 bytes (section then field, no padding), comfortably inside the Hook API’s 1-to-32-byte parameter-name bound. Unlike an oversized HookData state value (no size cap at all), #[derive(ParamName)] checks this bound — both the 1-byte lower bound and the 32-byte upper bound — at the struct’s own definition, so an out-of-range name fails to compile before it’s ever used.

Because AdminName is composite rather than a fixed byte string, its name-encoding has to actually run at runtime — laying section and field out into a small buffer sized exactly to AdminName::MAX_LEN. Measured on examples/12_typed-data: a small, fixed number of worst-case instructions over the same hook without the composite name, versus the near-zero cost of the plain CFG tag used elsewhere in that same hook.

Signature parameters (fn arguments)

This section’s surface requires the unstable-param-sig-interface feature: rshooks = { version = "…", features = ["unstable-param-sig-interface"] }. unstable-* features track draft specs and are exempt from semver — breaking changes may land in a minor release while the spec is a draft.

Both surfaces above pair a name with a value type through this crate’s own choices (a struct field, default/required). The Hook Parameter Signature Interface is a different, protocol-level convention: a HookParameterName wire format that makes a Hook’s declared parameters a machine-readable, typed function signature, and rshooks maps it onto the most direct possible Rust surface — extra arguments on the entry fn itself. The normative rules live in docs/PARAM_SIGNATURE_DESIGN.md; this section covers the day-to-day surface built on it.

#[hooks]
impl Increment {
    /// increment(account: AccountID, count: UInt16)
    #[hook(0, on = [Invoke])]
    fn increment(&self, account: AccountId, count: u16) -> HookResult {
        // `account` and `count` are already decoded here.
        ...
    }
}

(from examples/19_param-signature, the interface draft’s own worked example.) Every argument after &self on a #[hook(..)] fn declares one signature parameter, in declaration order — the argument’s position (0-based) is its wire index, its identifier is its display name, and its type picks the wire type byte. #[cbak(..)] fns cannot declare extra arguments: a callback’s originating transaction is the emitted transaction, not the invocation, so the interface doesn’t apply there.

The wire name

Each declared parameter’s HookParameterName is a fixed-layout byte string, 8 to 23 bytes total:

bytesmeaning
0x5F 0x50 0x53"_PS" interface identifier
0x00version
1 byteindex, 0x00..=0x0F (so at most 16 arguments per entry)
1 bytethe type code (see the type table below)
1 bytename length, 0x01..=0x10
1 to 16 bytesthe display name, [A-Za-z][A-Za-z0-9]* (no _)

rshooks builds this name entirely at macro/compile time — never at runtime — and validates every one of those rules (index range, a supported type byte, the name’s charset/length) as a const-evaluable assert, so a malformed declaration is a compile error, not a deploy-time or runtime surprise. A Rust identifier containing _ (the common case — min_amount, say) therefore cannot be used as a signature-parameter argument name; rename it, or fall back to the escape hatch below with an explicit name literal.

Supported types

Rust typetype codewire payload
u80x10 (STI_UINT8)1 byte
u160x01 (STI_UINT16)2 bytes
u320x02 (STI_UINT32)4 bytes
u640x03 (STI_UINT64)8 bytes
[u8; 16]0x04 (STI_UINT128)16 bytes
[u8; 32] / Hash0x05 (STI_UINT256)32 bytes
AmountBytes0x06 (STI_AMOUNT)8 (native) or 48 (IOU) bytes
Blob<N>0x07 (STI_VL)1 to min(N, 256) bytes
AccountId0x08 (STI_ACCOUNT)20 bytes
[u8; 20]0x11 (STI_UINT160)20 bytes
IssueBytes0x18 (STI_ISSUE)20 (native, all-zero) or 40 (issued) bytes
CurrencyCode0x1A (STI_CURRENCY)20 bytes
XFL0x80 (XFL, XAS-010d non-standard)8 bytes

Every integer type here decodes big-endian, unlike this crate’s own ToBytes/FromBytes little-endian convention covered earlier on this page and in Typed Data with Derives — a signature parameter’s value crosses the same protocol boundary a raw otxn_field/otxn_param read does (see Reading the Originating Transaction), not this crate’s own hook-private wire format. Blob<N>/IssueBytes are new types in rshooks::sig; every other row is a type this page and Typed Data with Derives already cover. XFL’s payload is the Hook API’s int64_t XFL bit pattern, big-endian — XFL::raw_bits() — decoded via XFL::from_raw_bits with no validity check; the type codes themselves come from XAS-010d (Hook Type Codes), which both this interface and the Hook State Interface reference for their type codes.

The generated prologue and its rollback

For each declared argument, the #[hooks]-generated code ahead of the entry’s body reads the value via otxn_param (against the full declared name above) and decodes it per the argument’s type. On any failure — absent, or the wrong length/shape for the declared type — it rolls back immediately, with:

  • message b"rshooks: bad sig param '<name>'"
  • code = the argument’s own 0-based index

before the body ever runs, so the body never sees a partially-decoded invocation. See examples/19_param-signature for this rollback exercised end-to-end (both a hand-written unit test via rshooks_testenv::TestEnv::invoke, and e2e).

The >= 16 convention for hook-authored rollback codes

Because the generated prologue’s own rollback code is always an argument index, 0x00..=0x0F (0..=15 — the interface’s own index bound), any rollback!/hook_errors! code your own entry body uses has to stay clear of that whole range, or the two rollback sources (the generated prologue, and your own body) become ambiguous by code alone: a caller inspecting HookReturnCode in isolation can no longer tell “argument 3 was malformed” from “my own error variant 3” without also checking the message. Every rshooks example that declares signature parameters and its own hook_errors! enum (currently just examples/19_param-signature) follows the same fix: number every hook-authored variant from 16, one past the highest possible argument index, rather than the usual 1. This is a convention, not something the macro enforces — hook_errors! accepts any i64 discriminant — but it is the one this crate’s own examples use consistently for any signature-parameter-declaring entry, and is worth adopting in your own hooks for the same reason.

The escape hatch

Per the standing rule that every macro surface documents its raw counterpart, rshooks::sig exposes the same name-building and decoding directly, for a hand-rolled read outside the entry-fn-argument surface:

use rshooks::sig::otxn_sig_param;
use rshooks::sig_name;

const COUNT_NAME: [u8; 12] = sig_name!(1, u16, b"count");
let count: rshooks::error::Result<u16> = otxn_sig_param(&COUNT_NAME);

sig_name!(index, Type, name) resolves the wire name and type byte for you at compile time; sig_param_name (also in rshooks::sig) is its lower-level, non-macro counterpart. See crates/rshooks/src/sig.rs’s own rustdoc for the full trait (SigParamType) and every type’s decode contract.

Generated SetHook declarations

An entry with signature-parameter arguments needs its parameters declared, not just read — the interface requires an on-ledger declaration entry (HookParameterValue = 0x00) at SetHook time for every parameter the entry’s signature names. rshooks build generates this automatically: any #[hook(..)] entry with signature-parameter arguments gets a HookParameters block in sethook.template.json, one entry per declared argument, in index order — see Per-Hook Attributes and the SetHook Template for the exact shape and where it sits among that entry’s other generated fields. This supersedes the general “HookParameters is never generated” rule only for declared signature parameters; an ordinary #[hook_param(...)]/#[otxn_param(...)] field (covered earlier on this page) still never appears there.

Why this prevents name/value mismatches

The loose hook_param_exact::<T>(name)/otxn_param_exact::<T>(name) calls take name and T as two independent arguments — a typo or a copy-paste error can pair the right name with the wrong type, or the wrong name with the right type, and both compile fine as long as T: FixedRead. A #[hook_param(...)]/#[otxn_param(...)] field removes that degree of freedom: the field’s declared name is permanently tied to exactly one value type, so TypedData.hook_param.config.get_or_default() (read from a free function outside the impl) and self.hook_param.acct.get_required() (read directly inside examples/09_state-foreign’s &self entry) can never accidentally decode one parameter’s bytes as the other’s struct shape — the compiler resolves the return type from the field itself, with no independently-chosen type left for a mismatch to hide in. This is the identical safety property Hook State’s #[state(...)] fields give the key/value side; see Typed Data with Derives for the underlying ParamName/ParamValue derives both build on, and Hook Chains for how a field declared once here is shared across every Hook entry in the same chain.

Typed Data with Derives

Hook state values, hook-state keys, and Hook API parameter names/values all share the same underlying shape: a fixed-size, named-field Rust struct that needs to cross the boundary into and out of a protocol byte buffer. This page covers the conversion traits that shape rests on (ToBytes/ FromBytes/FixedRead), the four derive macros that generate them for your own structs, and why those derives cost nothing over hand-packing the bytes yourself. Hook State and Hook and Transaction Parameters both build directly on what’s covered here.

The conversion traits

Two small traits fix exactly how a fixed-size value crosses the boundary:

pub trait ToBytes {
    const MAX_LEN: usize;
    fn write(&self, buf: &mut [u8]) -> usize;
}

pub trait FromBytes: Sized {
    fn read(buf: &[u8]) -> Result<Self>;
}

ToBytes::write encodes self into the front of buf, returning Self::MAX_LEN on success or 0 if buf is too short — never a partial write. FromBytes::read decodes Self from buf, failing with HookError::TooSmall if buf is shorter than expected. Every primitive this crate cares about implements both: u8/u16/u32/u64/i64, XFL, [u8; N] for any N, and every rshooks::types newtype (AccountId, Hash, CurrencyCode, …).

A third trait, FixedRead, backs the *_exact family covered in Reading the Originating Transaction and Hook State — it reads a value in one shot from a caller-buffer host call (otxn_field, hook_param, state, slot), by allocating exactly its own fixed-size buffer and requiring the read to fill it exactly.

Every field’s byte layout follows one crate-wide convention: little-endian, back-to-back, in declaration order. This is deliberately the opposite convention from the originating transaction’s own wire fields, which are big-endian Xahau Binary — see Reading the Originating Transaction’s “Decoding a raw field” section for the full two-world rule. ToBytes/FromBytes are for hook-private data: state and parameter values this crate’s own typed layer wrote, never protocol fields read directly off the transaction.

Four derives, four narrow roles

A struct used as a state key, a state value, a parameter name, or a parameter value all share the same “fixed-offset, named-field struct” shape — but they play genuinely different roles, so rshooks keeps them as four separate, narrower derives rather than one derive covering everything:

deriverolegeneratescan be read back?
HookDatahook-state valueToBytes + FromBytes + FixedRead + LENyes
HookKeyhook-state keyToBytes + StateKeyEncode (≤32-byte check)no
ParamNameparameter nameToBytes only (1–32-byte check)no
ParamValueparameter valueFromBytes + FixedRead onlyyes (that’s all it’s for)

The roles are deliberately narrow:

  • A key or a name is only ever encoded outward — handed to state/hook_param to locate something — never read back and decoded as itself. HookKey/ParamName reflect that by generating no FromBytes/FixedRead/LEN at all: trying to read one back as a value fails to compile with an ordinary trait-bound error.
  • A value or a payload (HookData/ParamValue) is what actually gets read back and interpreted, so it gets the read-side traits. ParamValue specifically generates no ToBytes, since a hook never writes its own parameters (hook_param_set writes a different hook’s parameter, taking a raw &[u8], not a typed value).
  • Only HookKey, a state_keys! enum, or types::StateKey implements StateKeyEncode — an ordinary HookData value struct does not automatically qualify as a key, so a state value can never be passed where a key is expected by accident, and vice versa. The same separation holds for ParamName vs. ParamValue.
  • HookKey and ParamName each carry a size bound the Hook API itself imposes, checked at the struct’s own definition, before it’s ever used: HookKey rejects anything over 32 bytes (a hook-state key’s fixed space); ParamName rejects anything outside 1–32 bytes (the Hook API’s own parameter-name bound, which additionally has a lower bound a state key doesn’t). HookData/ParamValue have no such cap — a state value or parameter payload isn’t limited that way (beyond rshooks::state’s own 32-byte typed-storage convenience limit, which the raw api::state functions bypass for a larger type).

Because a HookData struct also happens to satisfy ParamValue’s FromBytes/FixedRead requirement, it can be used directly as a parameter value — ParamValue is the narrower, intent-revealing choice for a struct that’s only ever a parameter payload and never a state value.

Nesting

A derived struct can be a field of another derived struct — since every derive only ever requires a field’s type to implement the traits it needs, and every derived struct already does, nesting needs no special support:

use rshooks::HookData;

#[derive(HookData)]
struct Inner {
    count: u32,
}

#[derive(HookData)]
struct Outer {
    tag: u8,
    inner: Inner,
}

assert_eq!(Outer::LEN, 1 + 4);

The full byte image

Every field is encoded back-to-back, little-endian, in declaration order — no padding, no per-field length prefix, no reordering. examples/12_typed-data and this crate’s own doctests pin this down byte-for-byte, not just as a round-trip:

use rshooks::HookData;
use rshooks::convert::ToBytes;

#[derive(HookData, Clone, Copy)]
struct FullImage {
    a: u8,
    b: u16,
    c: u32,
    d: u64,
}

let value = FullImage {
    a: 0x11,
    b: 0x2233,
    c: 0x4455_6677,
    d: 0x8899_AABB_CCDD_EEFF,
};

let mut buf = [0u8; 15];
assert_eq!(value.write(&mut buf), 15);
assert_eq!(FullImage::LEN, 15);

let mut expected = [0u8; 15];
expected[0..1].copy_from_slice(&0x11u8.to_le_bytes());
expected[1..3].copy_from_slice(&0x2233u16.to_le_bytes());
expected[3..7].copy_from_slice(&0x4455_6677u32.to_le_bytes());
expected[7..15].copy_from_slice(&0x8899_AABB_CCDD_EEFFu64.to_le_bytes());
assert_eq!(buf, expected);

u8 + u16 + u32 + u64 = 1 + 2 + 4 + 8 = 15 bytes, at offsets 0, 1, 3, 7 — exactly the field declaration order, nothing more.

A worked example: examples/12_typed-data

That example declares composite key/value and name/value structs with the derives this page covers, then wires each into a #[hooks] struct field — covered in full in Hook State and Hook and Transaction Parameters:

// Per-account deposit record key: a tag byte + AccountId.
#[derive(HookKey, Clone, Copy)]
struct DepositKey {
    tag: u8,
    owner: AccountId,
}

// Per-account deposit record value.
#[derive(HookData, Clone, Copy)]
struct DepositValue {
    amount: u64,
    deadline: u32,
    flags: u8,
}

// Install-time configuration, read from the `CFG` Hook parameter.
#[derive(ParamValue)]
struct Config {
    min_amount: u64,
    lock_ledgers: u32,
}

// Per-invocation instruction, read from the `INS` originating-transaction
// parameter.
#[derive(ParamValue)]
struct Instruction {
    action: u8,
    amount: u64,
}

#[hooks]
pub struct TypedData {
    /// Per-account deposit record, keyed by [`DepositKey`].
    #[state(key_by = DepositKey)]
    deposits: State<DepositValue>,

    /// Install-time configuration (`CFG`).
    #[hook_param(name = b"CFG", default = Config { min_amount: DEFAULT_MIN_AMOUNT, lock_ledgers: DEFAULT_LOCK_LEDGERS })]
    config: HookParam<Config>,

    /// Per-invocation instruction (`INS`). Missing or malformed is a
    /// rollback, never a silent default.
    #[otxn_param(name = b"INS", required)]
    instruction: OtxnParam<Instruction>,
}

DepositKey gets HookKey-equivalent codegen; DepositValue/Config/ Instruction get HookData/ParamValue-equivalent codegen — the field attributes (#[state(key_by = ...)], #[hook_param(...)], #[otxn_param(...)]) tie each field’s key/name to its value type, the struct-field equivalent of HookState’s pairing form. Used directly inside the #[hooks] impl via a &self entry, with no manual byte packing anywhere:

let deposit = self.state.deposits.at(DepositKey { tag: DEPOSIT_TAG, owner });
let current = deposit.get()?.unwrap_or(EMPTY_DEPOSIT);
// ...
deposit.set(&next)?;

What the derives replace

Without them, DepositKey/DepositValue would need hand-written encode/ decode functions — every field’s offset counted by hand, every reader kept in sync with every writer by hand:

// Key: tag (1 byte) || owner (20 bytes) — 21 bytes total, sent to the
// host exactly as-is (the host itself left-pads a key shorter than its
// fixed 32-byte storage width — see "Key length and padding" in Hook
// State — no local zero-padding here).
fn make_key(owner: &AccountId) -> [u8; 21] {
    let mut out = [0u8; 21];
    if let Some(b) = out.get_mut(0) {
        *b = DEPOSIT_TAG;
    }
    if let Some(dst) = out.get_mut(1..21) {
        dst.copy_from_slice(owner.as_ref());
    }
    out
}

// Value: amount (8 bytes LE) || deadline (4 bytes LE) || flags (1 byte).
fn encode_value(v: &DepositValue) -> [u8; 13] {
    let mut out = [0u8; 13];
    if let Some(dst) = out.get_mut(0..8) {
        dst.copy_from_slice(&v.amount.to_le_bytes());
    }
    if let Some(dst) = out.get_mut(8..12) {
        dst.copy_from_slice(&v.deadline.to_le_bytes());
    }
    if let Some(b) = out.get_mut(12) {
        *b = v.flags;
    }
    out
}

#[derive(HookData)] generates the equivalent of this — the same fixed, compile-time offsets, the same .get_mut()-guarded fixed-size copies — once, from the struct definition itself, and keeps ToBytes/FromBytes in sync automatically as fields are added, removed, or reordered.

The zero-cost claim: measured, not assumed

Every field offset in a derived struct is a compile-time constant, and every field read/write delegates straight to that field’s own ToBytes::write/FromBytes::read — no per-field loop, and (for a total size this toolchain’s release profile still lowers to inlined stores rather than a memset/memcpy builtin call) no unguarded loop at all. examples/12_typed-data backs this with a real rshooks build/check measurement: this hook’s core deposit-ledger logic, built twice — once with the derives as committed (the numbers in its metrics.json), once with all four hand-packed instead, everything else byte-for-byte identical.

The derived version isn’t just as cheap — it measures cheaper in both worst-case instructions and wasm size: the generated write/read check the struct’s total length once (buf.get_mut(..Self::MAX_LEN)), then copy every field through already-proven-in-bounds fixed offsets, whereas naive hand-packing re-checks bounds with a separate .get()/.get_mut() call per field. A hand-written version that front-loads one length check the same way could match the derive’s number — the point is the derive always generates that shape, by construction, without a hook author having to discover and apply the trick themselves. Both versions are guard-clean at the source level; neither needs a hand-written guard.

Composite parameter names have one caveat: unlike a plain byte-string tag (CFG/INS above, free — the wire encoding is the in-memory bytes, handed to the host with no copy), a struct-shaped name like AdminName in Hook and Transaction Parameters has to actually run its write() at runtime, since Rust has no stable way to run a trait method at compile time. That’s still measured cheap — a small, fixed number of worst-case instructions in that example — see that page’s “Composite names” section for why it can’t go to zero.

What each derive rejects at compile time

All four share the same field grammar: a plain, non-generic, named-field struct with at least one field, every field a fixed-size type implementing the traits that derive needs. An enum, a tuple struct, or a unit struct is rejected:

use rshooks::HookData;

#[derive(HookData)]
enum NotAStruct {
    A,
    B,
}

A field of a variable-length type (a bare slice, a Vec, …) fails with rustc’s own trait-bound error against the generated impls, naming the missing trait — the derive doesn’t implement its own type checker. And a HookData value struct doesn’t automatically work as a key:

use rshooks::HookData;
use rshooks::prelude::*;

#[derive(HookData)]
struct NotAKey {
    a: [u8; 20],
}

// ERROR: `NotAKey` has no `StateKeyEncode` impl — use `HookKey` for a key.
let _ = state_get::<u64>(&NotAKey { a: [0; 20] });

For the complete grammar, every generated item, and the full set of compile_fail examples pinning each misuse, see the HookKey/HookData/ ParamName/ParamValue derives’ own rustdoc — this page summarizes the parts most relevant to everyday hook code.

XFL: Decimal Floating Point

Xahau amounts and rates are not f64. They are XFL, a 64-bit decimal floating-point format the Hook API itself defines: a sign bit, an 8-bit biased exponent, and a 54-bit mantissa normalized to exactly 16 significant decimal digits. Every arithmetic operation on an XFL value is a host call — rshooks never computes on the bit pattern itself — because only the host knows how to normalize a result and detect overflow the same way the rest of the ledger does. This page covers the XFL type, how to construct values (including at compile time), the checked operators and comparison methods, reading an Amount field as XFL, and XFLUnchecked for hot paths that can defer validation to the end of a chain.

Why not f64

f64 cannot represent most decimal fractions exactly — 0.1 in binary floating point is already an approximation. XFL exists precisely to avoid that: every decimal value expressible in 16 significant digits has exactly one correct XFL encoding, bit-for-bit. Any bridge from decimal text to XFL that routes through f64 reintroduces the very rounding error XFL is designed to eliminate — which is why the XFL! literal macro below never touches f64 at all.

The XFL type

rshooks::xfl::XFL wraps a raw XFL bit pattern. The inner value is private: a host XFL call can return a negative value down the same i64 channel used for float results, so a public field would let an error code masquerade as a value. Two explicit escape hatches cross that boundary:

use rshooks::xfl::XFL;

let one = XFL::one();
let bits = one.raw_bits();
let same = XFL::from_raw_bits(bits);
assert_eq!(same.raw_bits(), bits);

XFL::one() and XFL::new(exponent, mantissa) never need f64-style rounding, but only XFL::one() is guaranteed not to fail — it’s the fixed bit pattern for 1.0, a const fn, no host call. XFL::new builds a normalized value from a runtime-computed exponent/mantissa pair, via the float_set host call:

let min_share = XFL::new(exponent, mantissa)?;

XFL’s mantissa is always normalized to 16 significant digits (10^15 to 10^16 - 1), so 0.000001 is not written as exponent -6 with mantissa 1 — it has to be mantissa 1_000_000_000_000_000 (1e15) with exponent -21, since 1e15 * 10^-21 == 10^-6. Getting the mantissa/exponent split wrong is an easy mistake, and XFL::new returning Result rather than silently normalizing is what catches it — but for a fixed constant known at compile time, like 0.000001 itself, XFL! below does that split for you and needs no Result at all; keep XFL::new for a pair actually computed at runtime.

The XFL! compile-time literal macro

For a fixed constant, hand-computing that mantissa/exponent split — or the raw bit pattern — is exactly the kind of arithmetic a macro should do instead. XFL! takes a decimal literal and expands, at compile time, to XFL::from_raw_bits(<bits>i64):

use rshooks::XFL;
use rshooks::xfl::XFL as XflType;

const DEFAULT_REWARD_RATE: XflType = XFL!(0.003333333333333333);
assert_eq!(DEFAULT_REWARD_RATE.raw_bits(), 6_038_156_834_009_797_973);

Because the expansion is XFL::from_raw_bits, a const fn, the result works directly in const/static position:

use rshooks::XFL;
use rshooks::xfl::XFL as XflType;

const ONE: XflType = XFL!(1);
static REWARD_DELAY: XflType = XFL!(2600000);
assert_eq!(ONE.raw_bits(), 6_089_866_696_204_910_592);
assert_eq!(REWARD_DELAY.raw_bits(), 6_199_553_087_261_802_496);

The macro parses the literal’s text by hand — integer arithmetic on the digit string — rather than going through f64, which is the whole point: bit-exactness for every representable decimal, not an approximation.

Grammar. An optional leading -, then exactly one numeric literal token: a plain integer (123456789, optionally with _ separators like 1_000_000), a decimal (0.1, 1., 1.50), or either with a decimal exponent (1e-5, 2.6E6, 1e+3). Trailing zeros are normalized away, so 1.50, 1_000, and 2600000 encode exactly as if written 1.5, 1e3, and 2.6e6.

What gets rejected, always as a compile_error!, never a panic:

  • anything that is not a single numeric literal token — a string/char/byte literal, a hex/octal/binary integer (0x../0o../0b..), missing input, or extra tokens
  • a numeric type suffix (1i64, 1.0f64) — XFL! always produces its own i64 expansion, so a suffix can only be a mistake
  • more than 16 significant decimal digits after trailing-zero normalization — XFL’s mantissa cannot hold them, and the macro never silently rounds
  • a magnitude outside XFL’s representable range, roughly 1e-81 to 1e96 (unbiased exponent bounds -96..=80) — reported as a distinct “too small” or “too large” message
// More than 16 significant digits.
rshooks::XFL!(1.2345678901234567);
// Magnitude too large to represent.
rshooks::XFL!(1e96);

Checked arithmetic

XFL implements Add, Sub, Mul, Div, and Neg — but every one of these has Output = Result<XFL, HookError>, not a bare XFL. Add/Mul/Div/Neg each issue one host call: self + rhs is float_sum, self * rhs is float_multiply, and so on, and Neg is a real float_negate round trip, never a local sign-bit flip. Sub is self + rhs.negated(): also one host call (float_sum), since negating the right-hand side is a local sign-bit flip (XFL::negated), not the Neg operator’s host round trip — there is no dedicated float_subtract host function either way.

let remaining = match amount - share {
    Ok(x) => x,
    Err(_) => rollback!(b"xfl-math: amount - share failed", ...),
};

(from examples/07_xfl-math). Because every operator’s Output is a Result, rshooks also implements the mixed combinations Result<XFL> op XFL and XFL op Result<XFL>, so a chain that alternates a plain XFL in on each side short-circuits on the first error without an explicit ? between every step. (Rust’s orphan rules forbid Result on both sides of one of these impls, so an independently-fallible value on each side still needs a ? first.)

mulratio(round_up, num, den) computes self * (num / den) in one host call — used for percentage-style scaling:

let share = amount.mulratio(false, 1, 100)?; // 1% of `amount`, rounding down

It takes two extra scale parameters beyond a plain rhs, so it stays a named method rather than trying to fit an operator shape.

Comparison: methods and operators

.eq(rhs), .lt(rhs), .gt(rhs), and .compare(rhs, mode) all return Result<bool>, backed by the fallible float_compare host call. .compare() takes a bitmask (COMPARE_EQUAL, COMPARE_LESS, COMPARE_GREATER, freely combined — e.g. COMPARE_LESS | COMPARE_EQUAL for <=, since there’s no dedicated le/ge method).

XFL also implements PartialEq/PartialOrd (==, <, >, …), forwarding to those same methods — but these traits have a fixed bool/Option<Ordering> return type with no room for an Err, so on a float_compare failure they fall back to false/None, the same convention f64 uses for NaN. That is the wrong choice whenever a comparison gates a rollback decision on an operand that hasn’t been separately validated — silently treating “the comparison failed” the same as “not below the minimum” would mean accepting a transaction the hook never actually validated. Prefer the Result-returning methods, matched three ways, for exactly those cases:

match share.lt(min_share) {
    Ok(true) => rollback!(b"xfl-math: computed share below minimum", ...),
    Ok(false) => {}
    Err(_) => rollback!(b"xfl-math: comparison failed", ...),
}

The operators are reasonable specifically when both operands are already host-validated XFL values with no realistic path to a float_compare failure — a pure sanity check where “incomparable” and “not greater than” deserve identical handling:

if compounded > remaining {
    rollback!(b"xfl-math: compounded share unexpectedly exceeds remaining amount", ...);
}

(compounded and remaining here only exist because two earlier Results already returned Ok — see examples/07_xfl-math’s README for the full reasoning on when each style applies.)

Reading an Amount field as XFL

The typed slot layer (see Slots and Ledger Objects) reads an Amount field as XFL directly, working identically whether the amount is native (XRP/XAH) or an IOU:

let txn = SlotObject::from_otxn()?;
let amount: XFL = txn.get(sfAmount)?.as_xfl()?;

SlotObject<Amount>::as_xfl is a direct slot_float call. For a native amount, the result comes back in XAH units, not drops — the host builds it from the drop count as mantissa with exponent -6, then normalizes. Recover the drop count with xfl.to_int(6, false).

An equivalent route using the raw numbered slot API, reading the same field without the typed layer:

let txn_slot = otxn_slot(0)?;
let amount_slot = slot_subfield(txn_slot, sfAmount, 0)?;
let amount = XFL::from_slot(amount_slot)?;

Both call the same host function under the hood; the typed form just needs no slot numbers. XFL::sto/XFL::sto_set are the corresponding encode/decode pair for a serialized Amount buffer that isn’t already in a slot (e.g. building a transaction’s own Amount field by hand).

When the amount already came back as an AmountBytes::Iou — from otxn_field_typed(sfAmount) or a views::tx accessor (see Typed Views) — IouAmount::xfl() decodes its value straight from those bytes, with no slot at all:

let AmountBytes::Iou(iou) = otxn_field_typed(sfAmount)? else {
    accept!(); // native, not handled here
};
let value: XFL = iou.xfl()?;

It hands the host exactly the amount’s 8-byte value component via XFL::sto_set, never the full 48 bytes and never a local bit-reinterpret — the wire value component sets an always-on “not native” flag bit a real XFL never sets, so either shortcut would produce a wrong result.

XFLUnchecked for hot paths

rshooks::xfl_unchecked::XFLUnchecked is the deferred-validation counterpart to XFL: every operator is still a real host round trip — there is no local fast path — but with no guest-side Result branch between steps. A poisoned or invalid operand propagates through the host calls (an invalid input is rejected by the host and the result is INVALID_FLOAT’s own raw bits, itself a valid poison value to keep propagating), and a single .validate() call at the end turns the final raw value into a real Result<XFL, HookError>:

let compounded_raw =
    share.unchecked() * growth.unchecked() * growth.unchecked() * growth.unchecked();
let compounded = match compounded_raw.validate() {
    Ok(x) => x,
    Err(_) => rollback!(b"xfl-math: compounded share failed to validate", ...),
};

(from examples/07_xfl-math.) XFL::unchecked() is a zero-cost reinterpretation into XFLUnchecked — no host call — and the two types mix freely at a chain’s boundary (XFLUnchecked op XFL and XFL op XFLUnchecked are both implemented, treating the XFL side as implicitly unchecked), so the usual shape is: start from a known-valid XFL, run the hot loop in XFLUnchecked, validate once at the end.

validate() is implemented as float_sum(self, 0) — a real host round trip, not a guest-side range check — specifically so it reuses the host’s own validation gate instead of re-deriving XFL’s mantissa/exponent rules locally and risking drift.

This is only worth reaching for on a measured hot path. Benchmarked against a checked Result-chain of the same operations, XFLUnchecked’s marginal cost per chained multiply matches a hand-written raw host-call chain exactly (+3 instructions/op vs. the checked chain’s +14); for Sub (two host calls per step, since Neg isn’t free), it’s +5 vs. +27. The win is entirely about when validation happens — once, at the end, instead of once per step — never about skipping any host validation a correct implementation actually needs. Use XFL’s checked operators by default, and reach for XFLUnchecked only once a chain like this is the measured bottleneck.

Slots and Ledger Objects

The Hook API’s slot machine is a set of 255 numbered registers a hook can load deserialized ledger objects and transactions into, then navigate into their fields and array elements. rshooks gives you two ways to work with it: a raw layer that mirrors the host API one function per call, and a typed layer (SlotObject<T>) that replaces slot-number bookkeeping with Rust types. This page covers the typed layer in depth, measures it against the raw one, and explains why the raw numbered functions are deliberately kept out of the prelude.

The slot machine, briefly

A slot holds one deserialized object — a transaction, a ledger entry, or a field/array-element derived from one already loaded. Slots are numbered 1..=255; passing 0 as a target slot number asks the host to auto-assign one. Every slot a hook populates is freed automatically when the hook returns, so a short-lived hook that reads a handful of fields once never needs to think about cleanup at all — the cost model only starts to matter once a loop derives more slots than the 255-slot budget allows.

The typed layer: SlotObject<T>

rshooks::slot_obj::SlotObject<T> is a handle to one loaded slot, typed by what it holds. Slot numbers are auto-assigned by the host and never appear in hook source:

let account = SlotObject::from_keylet(&keylet_account(accid)?)?;
let seq: u32 = account.get(sfSequence)?.value()?;
let bal: XFL = account.get(sfBalance)?.as_xfl()?;

Four constructors load a root slot:

  • SlotObject::from_otxn() — the originating transaction
  • SlotObject::from_meta() — the originating transaction’s metadata (only available inside #[cbak])
  • SlotObject::from_keylet(&keylet) — the ledger object a keylet points at (see Keylets)
  • SlotObject::from_txn_hash(&hash) — a transaction looked up by hash

Once the loaded object’s ledger-entry or transaction type is known, rshooks::views::ledger/rshooks::views::tx give named field accessors built on top of exactly this constructor set (RippleState::from_keylet, Payment::from_slot, …) instead of a .get(sfXxx) call per field — see Typed Views.

.get(sfXxx) and subfield navigation

.get(key) derives a child slot and borrows the parent, so one loaded object can yield several children without reloading it:

let signers = SlotObject::from_keylet(&keylet_signers(accid)?)?;
let entries = signers.get(sfSignerEntries)?;      // SlotObject<STArray>
let first = entries.get(0u32)?;                   // SlotObject<STObject>
let who: AccountId = first.get(sfAccount)?.value()?;

The key decides both the navigation and the resulting type: an SField<T> constant (sfAccount, sfBalance, …) navigates a field and yields SlotObject<T>; a u32 index navigates an array element and yields SlotObject<STObject>. This is checked at compile time — SlotObject<STObject>::get(0) (indexing an object) and SlotObject<STArray>::get(sfAccount) (field-navigating an array) are both compile errors, not runtime surprises.

.value()

Once you’ve navigated to a leaf field, .value() reads it out, consuming the handle:

let dest_slot = txn.get(sfDestination)?;
let dest: AccountId = dest_slot.value()?;

value() is generated for the scalar and fixed-size types this layer understands — u8/u16/u32/u64, AccountId, Hash, CurrencyCode — plus the two amount-shaped types below. No turbofish is needed: the SField<T> (or the earlier navigation) already fixed T.

AmountBytes, IssueData, and CastTarget

SlotObject<Amount> and SlotObject<Issue> classify their contents by serialized length rather than assuming a shape:

pub enum AmountBytes {
    Native(NativeAmount), // 8 bytes
    Iou(IouAmount),       // 48 bytes
}

pub enum IssueData {
    Native,             // 20 bytes
    Iou(IssuedAsset),   // 40 bytes: currency and issuer
}

pub struct IssuedAsset {
    pub currency: CurrencyCode,
    pub issuer: AccountId,
}

SlotObject<Amount>::value()/take_value() return AmountBytes; SlotObject<Issue>::value()/take_value() return IssueData. Both reject an MPT-length encoding as HookError::ParseError rather than guessing — MPT amounts are out of scope for this layer since Xahau has no amendment for them yet. SlotObject<Amount>::as_xfl() (see XFL) is the more common route when what you actually want is the numeric value rather than the raw bytes, and it works identically for native and IOU amounts.

IouAmount itself gives back its (currency, issuer) identity without a separate decode step: .currency(), .issuer(), and .asset() (the pair, as an IssuedAsset) all borrow the wire bytes in place rather than parsing them. .matches_asset(&asset) compares an amount’s currency/issuer against an already-known IssuedAsset the same way — via buf_eq_20, never a memcmp loop — without constructing an intermediate IssuedAsset to do it:

let AmountBytes::Iou(iou) = payment.amount()? else {
    accept!(); // native, out of scope for this check
};
let asset = iou.asset(); // IssuedAsset { currency, issuer }
if iou.matches_asset(&expected) { /* ... */ }

try_cast::<U>() retypes a handle after checking the slot’s serialized type ID against U’s CastTarget implementation — STObject, STArray, Amount, Issue, u8/u16/u32/u64, Hash, AccountId, CurrencyCode all implement it. Any failure (a mismatch or an underlying host error) consumes the handle and best-effort clears the slot. assume_type::<U>() is the free, unchecked twin, for when the caller already knows the slot’s contents from context the type system can’t see.

slot_path! for multi-hop navigation

A chain of .get(a)?.get(b)?.get(c)? leaks every intermediate slot — each temporary handle is dropped without clearing, and nothing clears automatically on drop. slot_path! auto-assigns a slot for the first hop, then rewrites that same slot number in place for every later hop — the host skips the storage copy when the requested slot equals the parent slot — so a 10-hop path costs one slot, not ten, and clears nothing on the success path:

use rshooks::slot_path;

let signers = SlotObject::from_keylet(&keylet_signers(accid)?)?;
let first: AccountId = slot_path!(signers[sfSignerEntries][0u32][sfAccount])?.value()?;

The root is borrowed and never cleared (it’s the caller’s handle, evaluated once). A hop after the first that fails clears the ladder’s one slot before returning the error, so a failed lookup cannot leak the parent that produced it either.

Recycling with take_*

.value()/.as_xfl()/.raw()/.raw_exact() all consume the handle without clearing the slot — deliberately: this matches the C cost model exactly (a C slot_subfield followed by a slot() read leaks the slot identically), and an implicit clear would tax every read with an extra host call the C idiom never pays. For a short hook reading a few fields once, that’s the right tradeoff — the host frees every slot when the hook returns regardless.

A loop deriving one child slot per iteration is the case that actually needs to give slots back mid-execution: 255 is the whole per-execution budget, so a 300-iteration loop that derives a slot each time will run out. take_value()/take_xfl()/take_raw_exact() read and clear, on both the success path and the failure path:

let mut ok: u32 = 0;
let mut i: u32 = 0;
while i < LOOP_ITERATIONS {
    guard!(LOOP_ITERATIONS);
    i = i.wrapping_add(1);
    if let Ok(leaf) = slot_path!(root[sfSignerEntries][0u32][sfAccount]) {
        if leaf.take_value().map(|_: AccountId| true).unwrap_or(false) {
            ok = ok.wrapping_add(1);
        }
    }
}

examples/15_slot-objects proves this live: a 256-iteration loop of plain .get() + .value() calls (over the 255-slot budget) would exhaust the budget partway through, but the same loop through take_value() completes all 256 iterations — including a separate 256-iteration loop of failing take_value() calls, proving the clear happens on the failure path too, and one of failing try_casts, proving the same for cast failures.

Measured: typed vs. raw

examples/08_slot-ledger rewrote a raw numbered-slot walk (otxn_slot → slot_subfield → slot_exact) into the typed equivalent and built both at this workspace’s opt-level = 3, with and without clearing the slots afterwards.

Without clears — the apples-to-apples comparison, same host calls, same cleanup policy — the two builds are byte-identical: every typed wrapper is #[inline(always)] over the same host call, so the type layer adds nothing. With clears the two aren’t directly comparable: take_* clears on the failure path as well as success, while the raw code’s slot_clear calls only run after a successful read, so the typed build’s few extra instructions buy strictly stronger cleanup rather than being layer overhead. The committed build’s numbers are in examples/08_slot-ledger/metrics.json.

Why the raw numbered functions aren’t in the prelude

rshooks::api::slot (slot_set, slot_clear, slot_subfield, slot_subarray, slot_type, slot_count, slot_size, slot, meta_slot, and friends) mirrors the host API directly — plain u32 slot numbers, one function per host call. It addresses the exact same 255 registers SlotObject does. Calling slot_clear(3) while a SlotObject happens to hold slot 3 corrupts that handle’s meaning: it keeps looking valid but starts describing whatever the host puts there next. This is a logic hazard, not a memory-safety one (no unsafe is involved on either side), so nothing prevents it at the type level — the mitigation is that these functions are kept out of the prelude and reachable only through an explicit path (rshooks::api::slot::slot_clear, rshooks::api::otxn::otxn_slot), so mixing the two layers is at least always visible at the call site.

Reach for the raw layer only when a hook genuinely wants to place things in specific numbered slots and manage them itself. Otherwise, default to SlotObject: it costs nothing extra and the type system catches mistakes the raw layer can’t.

Keylets

A keylet is a 34-byte locator for a ledger object: a type prefix plus the 32-byte hash-derived index the protocol uses to find that object in the ledger’s state map. Almost every ledger read that isn’t the originating transaction itself starts by computing a keylet, then loading the object it points at into a slot. This page covers rshooks’s 26 typed keylet_xxx helpers (each with a keylet_xxx_into out-param twin — see “Why typed helpers” below), a worked example that computes and stores them, and account_id!, the companion macro for building compile-time r-address constants.

Why typed helpers, not one untyped function

The host exposes a single util_keylet function that takes a keylet_type plus up to six same-typed u32 components (a..f). Which of those six are used, how many, and what each one means — a raw value like a sequence number, or a pointer into the hook’s own linear memory for an account ID or hash — all depend silently on keylet_type. Nothing at the type level stops passing an account pointer where a sequence number was expected, and getting it wrong is either a runtime NO_SUCH_KEYLET/ INVALID_ARGUMENT, or worse, a keylet that silently resolves to the wrong object.

rshooks::api::keylet has one function per KEYLET_* constant instead, each taking exactly the arguments its own type needs as the real rshooks::types newtypes — keylet_account takes only an &AccountId, keylet_line takes two &AccountIds and a &CurrencyCode, keylet_offer takes an &AccountId and a u32 sequence. Each also has a keylet_xxx_into(out: &mut Keylet, ...) -> Result<()> out-param twin — writing straight into caller-supplied storage instead of returning a value — for a caller about to borrow the result into another buffer-taking call right away:

let mut keylet = Keylet::default();
keylet_account_into(&mut keylet, &owner)?;
key.with_key_bytes(|k| state_set(keylet.as_ref(), k))?;

The by-value form’s own scratch buffer has its address taken by the host call, which stops the optimizer from eliding the copy into the caller’s actual destination on return; writing straight into the caller’s own buffer has no such intermediate to copy from. The two forms are independent implementations rather than one delegating to the other — delegating measurably cost extra worst-case instructions at a call site that only used the by-value form, so keylet_xxx and keylet_xxx_into each call the host directly.

The 26 typed helpers

functionKEYLET_*ledger object addressed
keylet_hook(account)KEYLET_HOOK (1)account’s installed hook chain
keylet_hook_state(account, key, namespace)KEYLET_HOOK_STATE (2)one hook-state entry
keylet_account(account)KEYLET_ACCOUNT (3)account’s AccountRoot
keylet_amendments()KEYLET_AMENDMENTS (4)the ledger’s singleton Amendments object
keylet_child(parent)KEYLET_CHILD (5)a derived pseudo-account keyed one level below parent
keylet_skip(ledger_index)KEYLET_SKIP (6)a SkipList object (current, or as of a historical ledger)
keylet_fees()KEYLET_FEES (7)the ledger’s singleton FeeSettings
keylet_negative_unl()KEYLET_NEGATIVE_UNL (8)the ledger’s singleton NegativeUNL
keylet_line(a, b, currency)KEYLET_LINE (9)the trust line (RippleState) between two accounts
keylet_offer(account, seq)KEYLET_OFFER (10)account’s Offer created at sequence seq
keylet_quality(dir, high, low)KEYLET_QUALITY (11)the order-book directory page at a given exchange rate
keylet_emitted_dir()KEYLET_EMITTED_DIR (12)the singleton directory of outstanding emitted transactions
keylet_ticket(account, seq)KEYLET_TICKET (13)account’s Ticket at seq — see the note below
keylet_signers(account)KEYLET_SIGNERS (14)account’s SignerList
keylet_check(account, seq)KEYLET_CHECK (15)account’s Check created at sequence seq
keylet_deposit_preauth(owner, authorized)KEYLET_DEPOSIT_PREAUTH (16)a recorded deposit preauthorization
keylet_unchecked(hash)KEYLET_UNCHECKED (17)hash reinterpreted directly as a keylet index, unvalidated
keylet_owner_dir(account)KEYLET_OWNER_DIR (18)account’s owner directory root
keylet_page(root, high, low)KEYLET_PAGE (19)directory page high/low under directory root
keylet_escrow(account, seq)KEYLET_ESCROW (20)account’s Escrow created at sequence seq
keylet_paychan(src, dst, seq)KEYLET_PAYCHAN (21)the PayChannel from src to dst created at seq
keylet_emitted(hash)KEYLET_EMITTED (22)the EmittedTxn bookkeeping entry for hash
keylet_nft_offer(account, seq)KEYLET_NFT_OFFER (23)account’s NFTokenOffer created at sequence seq
keylet_hook_definition(hash)KEYLET_HOOK_DEFINITION (24)the account-independent HookDefinition for wasm hash hash
keylet_hook_state_dir(account, namespace)KEYLET_HOOK_STATE_DIR (25)the directory of account’s hook-state entries under namespace
keylet_cron(account, start_time)KEYLET_CRON (26)account’s Cron entry firing at start_time

keylet_line_for_asset(account, &asset) is a convenience wrapper over keylet_line for when the currency/issuer pair is already an IssuedAsset (the type IouAmount::asset() produces — see Slots and Ledger Objects) rather than two separate arguments: the trust line between account and asset.issuer in asset.currency. It has its own keylet_line_for_asset_into twin, same as the typed helpers above.

Every function returns Result<Keylet>. keylet_hook addresses the account’s installed hook chain; keylet_hook_definition addresses a single hook’s own account-independent definition — the two are easy to conflate but key different objects. Likewise keylet_owner_dir (an account’s own directory root) is distinct from keylet_page, which addresses one page of any directory once you already have that directory’s root index.

keylet_ticket has a known host limitation on the tested xahaud build: the host’s util_keylet rejects KEYLET_TICKET regardless of ticket_seq, even though the identical account/sequence shape works through the ledger_entry RPC and every structurally similar type (keylet_offer, keylet_escrow, keylet_check, keylet_signers) succeeds. The helper stays in rshooks — it matches the documented argument shape and a future host build may support it — but treat it as untested until your target node confirms otherwise.

A worked example

examples/13_keylets computes 25 of the 26 keylet types (everything but keylet_ticket, for the reason above) from the invoking transaction’s sfAccount/sfDestination plus a handful of fixed test inputs, and writes every 34-byte result into hook state:

let Ok(owner) = otxn_field_typed(sfAccount) else {
    rollback!(b"keylets: sfAccount missing from the originating transaction", ...)
};
let Ok(dest) = otxn_field_typed(sfDestination) else {
    rollback!(b"keylets: sfDestination missing from the originating transaction", ...)
};

let Ok(keylet) = keylet_account(&owner) else {
    rollback!(b"keylets: a keylet_xxx call failed", ...)
};
if KeyletKey::Account
    .with_key_bytes(|k| state_set(keylet.as_ref(), k))
    .is_err()
{
    rollback!(b"keylets: state_set failed", ...);
}

(condensed from examples/13_keylets/src/lib.rs, which repeats this shape once per keylet type using a small compute/store helper pair). Every keylet here is computed entirely from inputs already available at compile time or read directly off the invoking transaction — no other ledger object has to exist first, so the hook works against a bare node with no setup.

To go from a keylet to the object it addresses, load it into a slot (see Slots and Ledger Objects):

let account = SlotObject::from_keylet(&keylet_account(accid)?)?;
let seq: u32 = account.get(sfSequence)?.value()?;

account_id! for compile-time r-addresses

Several keylet arguments are &AccountId, but a classic Xahau/XRPL r-address (rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh) is base58-encoded text, not the raw 20-byte form the Hook API and keylet_xxx want. account_id! decodes that text entirely at compile time — base58 decode, version-byte check, and double-SHA256 checksum verification all run inside the proc macro at cargo build time, never inside the compiled wasm:

use rshooks::prelude::*;

const OWNER: AccountId = account_id!("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh");

Because the expansion is a bare AccountId([..]) literal, OWNER works in const/static position and the compiled wasm is byte-identical to hand-writing the 20-byte array yourself — examples/14_account-id-macro’s e2e suite asserts exactly that against a hand-written control. A malformed address (bad checksum, wrong length, wrong version byte) is a compile_error!, not a runtime failure:

// Bad checksum — last character altered.
rshooks::account_id!("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTH");

Reach for account_id! whenever a keylet argument, a hard-coded genesis account, or any other fixed r-address needs to become an AccountId — it replaces hand-computing or hex-pasting the 20 bytes yourself.

CurrencyCode::from_iso for 3-character currencies

keylet_line takes a &CurrencyCode. Standard ISO-style codes (USD, EUR, …) are only 3 ASCII bytes, but the on-ledger encoding is always 20 bytes: twelve zeros, the three characters, five more zeros. A 160-bit non-standard currency still uses the 20-byte tuple constructor; the 3-character form is from_iso, usable in const/static position:

use rshooks::prelude::*;

const USD: CurrencyCode = CurrencyCode::from_iso(b"USD");

The argument is &[u8; 3], so b"US" or b"USDT" is a type error rather than a silently-wrong encoding. Native XRP/XAH is a native amount, not from_iso(b"XRP").

Typed Views

rshooks::views generates one Rust struct per protocol format xahaud declares — one per transaction type, one per ledger-entry type, one per inner (nested) object type — each with a named, typed accessor per field. views::tx::Payment, views::ledger::RippleState, views::ledger::AccountRoot, and friends replace a run of otxn_field_typed(sfXxx)/.get(sfXxx)?.value() calls with payment.destination(), line.flags(), account.balance() — the same underlying reads (see Reading the Originating Transaction and Slots and Ledger Objects), with the field list and its value types taken directly from what upstream declared rather than assembled by hand at each call site.

Every struct and accessor is generated by cargo xtask gen-core from xahaud’s own vendored format macros — cargo xtask gen-core --check verifies the checked-in views are current — so the field lists are upstream’s, not this library’s opinion of them. The generator’s own hand-written runtime (rshooks::views::source) holds the one place every view’s absence/slot policy is decided; the generated files themselves are declarations only.

Two sources, one accessor shape

A transaction or ledger-entry view is generic over where its fields come from:

  • OtxnSource — reads go straight to the originating transaction (otxn_field), one host call per access. The cheapest source, and the only one a transaction view built with Xxx::otxn() uses.
  • SlotSource — wraps an already-loaded SlotObject<STObject>. Reads navigate to a child slot and read it — the only source that can reach into a container, and the only one a ledger-entry view has, since a ledger object is never the originating transaction.

Both are monomorphized and every accessor is #[inline(always)], so the choice of source compiles away: a view accessor is exactly the host call it wraps, nothing more.

Constructing a view

Three ways in, depending on where the object already lives and what kind of format it is:

use rshooks::views::{ledger, tx};

// A transaction view, off the originating transaction directly.
let payment = tx::Payment::otxn()?;

// A ledger-entry view, off a keylet.
let line = ledger::RippleState::from_keylet(&keylet)?;

// Either kind, off a slot already loaded some other way.
let payment = tx::Payment::from_slot(some_slot_object)?;

otxn() and from_slot() both check the object’s own type field before handing back a view — sfTransactionType for a transaction, checked against a raw tt* code; sfLedgerEntryType for a ledger entry, checked against a raw lt* code — so a keylet collision, a caller’s wrong assumption, or the wrong branch of an enum-shaped result surfaces as HookError::DoesNotMatch rather than as a field read that silently returns another object’s bytes. from_keylet does the same check after loading the slot itself. On any failure from_slot/from_keylet best-effort clear the slot they were given — a caller who was wrong about what it held is done with it.

Inner objects are the exception. views::inner types (SignerEntry, Signer, EmitDetails, HookExecution, …) have no type field of their own to check — nothing in the protocol declares one for a nested object — so their only constructor is an unchecked Xxx::from_slot(obj), taking ownership of a child slot the caller already navigated to (an array element, an object field) with no verification beyond what got you there.

Reading fields

Every accessor’s return type follows the field’s declared soe* requirement:

let dest: AccountId = payment.destination()?;      // soeREQUIRED -> Result<T>
let tag: Option<u32> = payment.destination_tag()?;  // soeOPTIONAL -> Result<Option<T>>

A required field missing from a well-formed object is HookError::DoesntExist; an optional or default-valued field reads as Ok(None) when absent — never confused with a read failure, and decided on the host’s raw return code rather than on a constructed HookError (the same shape otxn_field_typed uses — see Reading the Originating Transaction’s “Decoding a raw field” section for the general shape of that concern). soeDEFAULT fields read the same way as soeOPTIONAL: the format only records that the field may be omitted, never what a hook should substitute, so supplying a default is left to the hook.

A field whose serialized type is Amount/Issue reads back as AmountBytes/IssueData (see Slots and Ledger Objects), the same classify-by-length shapes the raw layer uses. A field whose serialized type this crate models no scalar for (Blob, STObject, STArray, a PathSet, …) gets a raw ..._into accessor instead, writing wire bytes into a caller buffer; on a SlotSource view, an STObject/STArray field additionally gets a ..._slot accessor that hands back an owned child slot to navigate further — the one place a view’s accessor does not clear after itself, since a container has no terminal read for “after” to mean anything.

Fields every format of a kind shares — sfFlags, sfSourceTag on every transaction; sfLedgerEntryType, sfFlags on every ledger entry — live once on common-field traits (TransactionCommonFields, TransactionCommonSlotFields, LedgerEntryCommonFields) that every generated view implements, re-exported by the prelude.

A worked example: gating incoming IOU payments

examples/18_typed-views accepts an incoming payment only when it is a native (XAH) payment — out of scope, accepted immediately — or an IOU payment that carries a sfDestinationTag, is denominated in a currency this account has an unfrozen trust line to the issuer for, and comes from an issuer charging no transfer fee:

let Ok(payment) = tx::Payment::otxn() else {
    rollback!(b"typed-views: not a Payment", ViewError::NotAPayment)
};

let Ok(amount) = payment.amount() else { .. };
let AmountBytes::Iou(iou) = amount else {
    accept!(b"typed-views: native payment, not gated", 0)
};

match payment.destination_tag() {
    Ok(Some(_)) => {}
    _ => rollback!(.., ViewError::MissingDestinationTag),
}

// The line that gates *receipt* is this account's line to the issuer of
// the currency being paid — which the payment's own Amount names.
let keylet = keylet_line_for_asset(&me, &iou.asset())?;
let Ok(line) = ledger::RippleState::from_keylet(&keylet) else { .. };

(condensed from examples/18_typed-views/src/lib.rs; iou.asset() and keylet_line_for_asset are covered in Slots and Ledger Objects and Keylets.)

A RippleState has no fixed “sender”/“receiver” side — the protocol sorts the two accounts canonically and calls the smaller one low, the larger high, so lsfLowFreeze/lsfHighFreeze alone can’t answer “did we freeze this line, or did they”. The example recovers the answer from the line itself rather than re-sorting the accounts: sfLowLimit is an IOU amount issued by the low account, so its issuer field is the low account:

match line.low_limit() {
    Ok(AmountBytes::Iou(low)) => buf_eq_20(&low.issuer().0, &me.0),
    _ => false,
}

The obvious alternative — comparing me < asset.issuer directly, now that AccountId has a loop-free Ord — looks cheaper (three fewer host calls) and is measurably not: on this workspace’s opt-level = 3 profile, low_limit() + buf_eq_20 costs fewer worst-case instructions than me < asset.issuer (buf_cmp_20), because a host call is one instruction in the worst-case count while buf_cmp_20 inlines a three-stage comparison ladder. “Fewer host calls” and “fewer instructions” are different objectives, and only the second is metered.

Cost

Every accessor is #[inline(always)] over a monomorphized source, so the abstraction itself is free: an OtxnSource accessor is exactly one otxn_field call, and calling it any number of times costs nothing beyond each individual read.

A SlotSource accessor spends one thing a hand-written raw read need not: every slot-backed read is get → read → clear, so a view’s accessors can be called any number of times while consuming zero slots beyond the view’s own root. A hand-written hook using SlotObject directly can skip that clear and leak the child slot — the right trade for a one-shot read (see Slots and Ledger Objects’s “Recycling with take_*”), and the wrong one for a view whose accessors might be called from a loop. The from_keylet/from_slot type check costs a few calls too — one sfLedgerEntryType/sfTransactionType read, itself a get → read → clear.

examples/18_typed-views’s accept path — the whole gate above, IOU branch, issuer charging no fee — is 18 host calls when the issuer sets no sfTransferRate and 20 when it does:

stepcallswhat
Payment::otxn()1otxn_type + one integer compare
amount()1otxn_field
destination_tag()1otxn_field
hook_account_buf()1hook_account
keylet_line_for_asset()1util_keylet
RippleState::from_keylet()4slot_set, then the sfLedgerEntryType check
line.flags()3slot_subfield + read + clear
keylet_account()1util_keylet
AccountRoot::from_keylet()4slot_set, then the sfLedgerEntryType check
transfer_rate()1 or 3absent: slot_subfield reports it missing. Present: + read + clear

Measured end to end (rshooks build/check, this workspace’s opt-level = 3 profile) and recorded in examples/18_typed-views/metrics.json — see that file for the current worst-case instruction count, wasm size, and max nesting depth for the main hook.

Every by-value fixed-size read above (hook_account_buf, keylet_line_for_asset, keylet_account) has an _into(out: &mut T, ..) -> Result<()> twin that writes straight into caller-owned storage. The by-value form is the idiom to write; the twin is an escape hatch for a result that is only ever borrowed into the next call, where it saves one copy of T per call site at the cost of a separate let mut x = T::default();. Measure with rshooks check before reaching for it — see the rshooks::api module docs.

Feature gates: which views exist

Upstream’s format tables include formats unavailable on Xahau mainnet — inherited wholesale from rippled, some amendment-blocked outright, some Xahau-native but not yet activated. crates/rshooks-core/format_availability.json is a curated, hand-maintained classification of every declared format into one of three tiers, and the generator gates each tier’s items with a #[cfg]:

tiermeaningdefaultactive-amendmentsall-amendments
activeactivated on Xahau mainnetyesyesyes
pendingsupported by xahaud, not yet activatedyesnoyes
dormantno activation prospect on Xahau mainnetnonoyes

active-amendments narrows the generated surface to formats actually live today; all-amendments widens it to include dormant formats too, for a custom network whose operator knows better. If both are enabled, all-amendments wins, so enabling a feature can only add API, never remove it. As of the vendored snapshot this crate ships against, no format is classified pending — every format is either active or dormant — so in practice the default surface and the active-amendments surface currently coincide; active-amendments exists to keep meaning that once a pending format is added, not to change anything today.

The sfield constants a view reads follow the same tiers, so a dormant view and its dormant-only fields compile together or not at all — there is no way to end up with a struct whose accessor references a field constant that isn’t itself available. views::tx::Payment, views::ledger::RippleState, and views::ledger::AccountRoot — everything used in the worked example above — are all active, so nothing in this page needs any feature flag. A dormant type (checked in crates/rshooks/src/views/ledger.rs/tx.rs/ inner.rs as #[cfg(feature = "all-amendments")]) needs:

rshooks = { version = "...", features = ["all-amendments"] }

The raw layers stay untouched and exhaustive regardless of any feature: crate::tx_type::TxType and crate::ledger_entry_type::LedgerEntryType decode every tt*/lt* code this crate knows about whether or not a typed view exists for it, and otxn_field/SlotObject’s raw reads (see Reading the Originating Transaction, Slots and Ledger Objects) work on any field regardless of tier.

What views don’t do

Reach past views for what they deliberately leave to the layers underneath:

  • No array iteration sugar. An STArray field gives back raw bytes or a SlotObject<STArray> handle (via a ..._slot accessor); iterate it with the slot API and wrap each element in a views::inner type by hand.
  • No builders. These are read views. Emitting a transaction is rshooks::txn’s and rshooks::sto_writer’s job — see Emitting Transactions.
  • No help for a dynamic or unknown type. A view asserts the type it claims. A hook reading one field, walking an array, or handling an object whose type isn’t known in advance is better served by rshooks::api::otxn or rshooks::slot_obj directly.

Emitting Transactions

A hook doesn’t just accept or reject the transaction that invoked it — it can also emit brand-new transactions of its own, which the network processes independently once this hook returns. This page walks through the emission lifecycle end to end: reserving emission slots, building a transaction with txn_template!, emitting it, and reacting to the outcome in a paired #[cbak(<index>)], using examples/10_emit-txn’s Payment template as the worked example throughout.

The emission lifecycle

  1. Reserve. Call etxn_reserve(count) before building or emitting anything — it tells the host how many transactions this invocation intends to emit, and every emit call after that must stay within the reserved count.
  2. Build. Fill in a transaction’s bytes — normally through a txn_template!-declared type (see below), which handles the protocol-level plumbing fields for you.
  3. Emit. Hand the finished bytes to emit, which returns the emitted transaction’s hash.
  4. React (optional). If this entry declares a paired #[cbak(<index>)], the host calls it later, when the emitted transaction actually settles on ledger — or bounces.
if etxn_reserve(1).is_err() {
    rollback!(b"emit-txn: etxn_reserve failed", EmitTxnError::ReserveFailed);
}

etxn_reserve must run before the corresponding emit call; it is not optional bookkeeping. rshooks::api::etxn also exposes the lower-level pieces this all rests on — etxn_burden, etxn_fee_base, etxn_generation, etxn_nonce/etxn_nonce_buf — for hooks that need them directly, but txn_template!’s prepare_for_emit() (below) already calls the ones a typical Payment-shaped emission needs.

txn_template!

rshooks deliberately does not ship a built-in PaymentTemplate type — any new field or transaction shape would then require a rshooks release. Instead, mirroring xahaud’s own C “Tx Builder” split, txn_template! is a declarative macro: you declare an ordered field list, and it generates a byte-exact, fixed-offset template plus typed setters, computed entirely at compile time.

txn_template! {
    /// A payment template for emitted transactions.
    struct Payment {
        transaction_type = ttPAYMENT,
        flags: sfFlags = tfCANONICAL,
        source_tag: sfSourceTag = 0,
        sequence: sfSequence = 0,
        destination_tag: sfDestinationTag = 0,
        first_ledger_sequence: sfFirstLedgerSequence = 0,
        last_ledger_sequence: sfLastLedgerSequence = 0,
        amount: sfAmount = NativeAmount(0),
        fee: sfFee = NativeAmount(0),
        signing_pub_key: sfSigningPubKey = [],
        account: sfAccount,
        destination: sfDestination,
        emit_details: emit_details,
    }
}

(from examples/10_emit-txn.) A bare field: sfXxx (or = default) infers its kind straight from sfXxx’s serialized type — flags: sfFlags above is exactly flags: u32_field(sfFlags), byte for byte. AMOUNT/VL fields (amount/fee/signing_pub_key above) cover more than one wire shape, so their kind can’t come from the STI alone — but it can still come from the shape of the default: NativeAmount(0) infers native_amount, [] infers empty_vl (the one spelling for an empty blob), and IouAmount(xfl, cur, iss)/[ .. ]/*b".." infer amount/fixed_vl the same way (see “amount: the 48-byte issued form” and “fixed_vl” below). The uniform kinds below remain fully supported and are what a bare field infers into; declare one explicitly only when neither the STI nor the default’s shape disambiguates it (native_issue/issue, a zero-default amount(sfX), or a fixed_vl default spelled as a named const rather than a literal) or when the field is a nested object/array you’d rather spell out. emit_details is the one structural marker (not a kind), and must be declared last:

kindserialized typewire bytes after headerdefaultsetter
u8_field(sfX) = eUINT81requiredset_x(u8)
u16_field(sfX) = eUINT162requiredset_x(u16)
u32_field(sfX) = eUINT324requiredset_x(u32)
u64_field(sfX) = eUINT648requiredset_x(u64)
hash128(sfX)UINT12816zeroedset_x(&[u8; 16])
hash160(sfX)UINT16020zeroedset_x(&[u8; 20])
hash256(sfX)UINT25632zeroedset_x(&Hash)
currency(sfX)CURRENCY20zeroedset_x(&CurrencyCode)
native_amount(sfX) = eAMOUNT8required dropsset_x(u64) -> Result<()>
amount(sfX)AMOUNT48IOU zero, zero currency/issuerset_x(xfl, &currency, &issuer), set_x_value(xfl)
amount(sfX) = (xfl, cur, iss)AMOUNT48the declared triplesame as above
native_issue(sfX)ISSUE20zeroednone
issue(sfX)ISSUE40zeroedset_x(&CurrencyCode, &AccountId)
account_id(sfX)ACCOUNT1 + 20zeroedset_x(&AccountId)
empty_vl(sfX)VL1empty blobnone
fixed_vl(sfX, N) = eVLVL-prefix(N) + Nzeroed, or the declared [u8; N]set_x(&[u8; N])
object(sfX) { .. }OBJECTinner + 1 (0xE1)inner defaultsinner setters, prefixed
array(sfX) [ .. ]ARRAYelements + 1 (0xF1)inner defaultsinner setters, prefixed

Every kind checks, at compile time, that the declared sfXxx constant’s serialized type matches — u32_field(sfFee) (an issued/native AMOUNT field) is rejected rather than silently writing the wrong wire representation. Integer kinds are big-endian.

The macro computes cumulative byte offsets and the template’s total length at compile time, bakes the field headers and defaults into a const fn new() (so the whole thing lands in a wasm data segment — see the statics idiom below), and generates one setter per field that has one, per the table above:

txn.set_amount(1).is_err();       // Result<()> — native_amount setters can fail out-of-range
txn.set_destination(&dest);        // infallible — account_id setters

Required fields

An emitted transaction is invalid at the protocol level without six fields plus EmitDetails, so every txn_template! declaration must include all of them, each with the matching kind:

required fieldsfcodekind
SequencesfSequenceu32_field
FirstLedgerSequencesfFirstLedgerSequenceu32_field
LastLedgerSequencesfLastLedgerSequenceu32_field
FeesfFeenative_amount
SigningPubKeysfSigningPubKeyempty_vl
AccountsfAccountaccount_id
(structural)—emit_details

Sequence/FirstLedgerSequence/LastLedgerSequence/Account can all be declared with the inferred bare form (sequence: sfSequence = 0, account: sfAccount); Fee’s and SigningPubKey‘s STIs (AMOUNT/VL) are ambiguous on their own, but their defaults’ shapes still disambiguate them: fee: sfFee = NativeAmount(0), signing_pub_key: sfSigningPubKey = []. A missing required field, or one declared with the wrong kind (sfFee as u32_field instead of native_amount, say), is a compile error naming exactly which field and check failed — never a runtime surprise. Declared fields’ sfXxx codes must also be in strictly increasing canonical order, which is a compile error too (and incidentally catches an accidental duplicate field, since two equal codes can’t be strictly increasing).

amount: the 48-byte issued form

native_amount stays the 8-byte native (XRP/XAH) form; amount is always the 48-byte issued (IOU) form: [8-byte value][20-byte currency][20-byte issuer]. The value bytes are a pure bit transform of the XFL — xfl.raw_bits() | (1 << 63), big-endian — so encoding an amount field needs no host call, at compile time or at runtime: an XFL’s canonical bit layout already occupies the same positions STAmount’s issued 8-byte value uses, and setting the top bit is STAmount’s own “not native” flag.

An amount(sfX) field with no default reserves the canonical IOU zero with an all-zero currency and issuer; the host rejects an issued amount emitted in that state (a real issuer is required), so leaving it unset is an authoring bug the host surfaces at emit time, not something the macro catches. A declared default bakes the currency and issuer into the data segment instead:

amount: amount(sfAmount) = (XFL!(0), CurrencyCode::from_iso(b"USD"), account_id!("r...")),
// or, inferred straight from `sfAmount` and the default's shape:
amount: sfAmount = IouAmount(XFL!(0), CurrencyCode::from_iso(b"USD"), account_id!("r...")),

IouAmount(..) (like NativeAmount(..) above) is a syntax marker this desugar recognizes, not a real type. Two setters follow from that split:

  • set_x(xfl, &currency, &issuer) rewrites all 48 bytes.
  • set_x_value(xfl) writes only the 8 value bytes, keeping the baked or previously set currency/issuer — the intended hot path once a default triple has fixed the currency/issuer: one 8-byte store, no host call.

fixed_vl: a fixed-length blob

empty_vl(sfX) stays the empty blob; fixed_vl(sfX, N) is for a VL field whose length is fixed by the declaration rather than empty — a memo type code, a fixed-width tag, and the like. N (a usize const expression, at least 1) is part of the declaration, so rippled’s VL length prefix — one, two, or three bytes depending on N’s own magnitude — is computed and baked in at compile time, the same as every other kind’s header. N = 0 is a compile error: empty_vl is the one spelling for an empty blob, so sfSigningPubKey’s required-kind check keeps accepting only empty_vl.

memo_type: sfMemoType = *b"note",
memo_data: sfMemoData = [0; 8],

memo_type’s N = 4 and memo_data’s N = 8 are both inferred from the default literal itself (*b"note"’s/[0; 8]’s own length) — the explicit fixed_vl(sfMemoType, 4)/ fixed_vl(sfMemoData, 8) spelling still works unchanged, and is the only option when the default is a named const rather than a literal (there is then no literal to recover N from). Without a default the payload is N zero bytes; a declared default must be exactly [u8; N] — a wrong-length default is a compile-time type error, not a truncation. The setter, set_x(&[u8; N]), is an infallible fixed-size write. Only fixed-length VL is covered this way; a genuinely variable-length blob, Vector256, and PathSet stay out of scope (see “Deferred kinds” below).

fixed_vl works the same way inside a nested container — a homogeneous sfMemos array (see “Nested STObject/STArray” below) whose element declares both fields:

memos: sfMemos [
    Memo: sfMemo {
        memo_type: sfMemoType = *b"note",
        memo_data: sfMemoData = [0; 8],
    }; 1
],
let Some(mut memo) = txn.memos(0) else {
    rollback!(b"emit-txn: index out of range", EmitTxnError::IndexOutOfRange);
};
memo.set_memo_data(b"payload!");

examples/21_txn-template-nested carries exactly this memo alongside its issued-amount entries.

Nested STObject/STArray

object(sfX) { <field>* } and array(sfX) [ .. ] nest a fixed inner field list, or a fixed element list, directly inside a template — every element’s count and shape is known at declaration time, so the whole thing stays as compile-time-computable as the scalar kinds. An array’s elements must each be an object(sfX) { .. }; a bare scalar, or another array, directly inside an array is a compile error. array itself comes in two forms.

Array elements

Each element is declared individually, so heterogeneous shapes — one native entry, one issued entry — fall out naturally, reached by its zero-based position in the list:

txn_template! {
    struct Remit {
        transaction_type = ttREMIT,
        // .. the required fields, plus `destination: sfDestination` ..
        amounts: sfAmounts [
            sfAmountEntry {
                amount: native_amount(sfAmount) = 1,
            },
            sfAmountEntry {
                amount: sfAmount = IouAmount(XFL!(0), USD, USD_ISSUER),
            },
        ],
        emit_details: emit_details,
    }
}

txn.set_amounts_0_amount(5)?;                // first entry, 8-byte store
txn.set_amounts_1_amount_value(XFL!(1.5));   // second entry, 8-byte store

Setter names are the _-joined declaration path (set_amounts_0_amount, set_amounts_1_amount/set_amounts_1_amount_value); an element takes no name of its own — its position is just another path segment, not a repetition index. An explicit name (native: sfAmountEntry { .. }) is a compile error.

Homogeneous, indexed elements

When every element has the same declared shape, array(sfX) [ Elem: object(sfY) { <field>* } ; N ] declares that shape once and reserves N back-to-back copies of it (N a usize const expression, at least 1), instead of one setter per element:

txn_template! {
    struct Remit {
        transaction_type = ttREMIT,
        // .. the required fields, plus `destination: sfDestination` ..
        amounts: sfAmounts [
            AmountEntry: sfAmountEntry {
                amount: sfAmount = IouAmount(XFL!(0), USD, USD_ISSUER),
            }; 2
        ],
        emit_details: emit_details,
    }
}

let mut i: usize = 0;
loop {
    guard!(2);
    if i >= 2 {
        break;
    }
    let Some(mut e) = txn.amounts(i) else {
        rollback!(b"emit-txn: index out of range", EmitTxnError::IndexOutOfRange);
    };
    let Ok(value) = XFL::new(0, (i as i64).wrapping_add(1)) else {
        rollback!(b"emit-txn: XFL::new failed", EmitTxnError::AmountValueFailed);
    };
    e.set_amount_value(value);
    i = i.wrapping_add(1);
}

This generates an element-view type named Elem (AmountEntry above) — AmountEntry::LEN, a baked AmountEntry::TEMPLATE default, and the same inner setters (set_amount/set_amount_value) a template with that field list would generate itself, all writing into a &mut [u8] view — plus, on the parent, a runtime-indexed accessor named by the field path with no set_ prefix: fn amounts(&mut self, index: usize) -> Option<AmountEntry<'_>>. None for index >= N is the whole out-of-range story; there’s no txn.amounts[n] indexing operator (and no unsafe/#[repr(C)] behind the view type) — the workspace’s panic-on-out-of-range indexing lint would make a raw [n] unusable inside a hook anyway, so Option plus a guarded loop is the idiom.

Choosing between them, and shared rules

Individually declared elements read best when each entry’s shape genuinely differs (a native amount next to an issued one, say); the homogeneous indexed form is for a repeated element shape whose count is fixed at declaration time, built or inspected through a loop rather than addressed one at a time. A few rules apply either way, once containers nest:

  • Canonical (type, field) order is checked per container: each object’s own direct fields must be strictly increasing, same as the template’s top-level fields. An array’s elements are not order-checked against each other — they typically share one repeated sfcode (every sfAmounts element here is an sfAmountEntry).
  • Container headers and end markers (0xE1 closing an object, 0xF1 closing an array) are written at compile time, same as every other baked byte.
  • Nesting depth is bounded at compile time by STO_WRITER_MAX_DEPTH, the same limit xahaud’s deserializer enforces — a homogeneous array’s element counts as two levels against that bound (the array itself, then the element), the same as an array’s own positional object element.
  • The six emit-plumbing fields (see “Required fields” above) are recognized only at the top level — an sfAccount nested inside some other object neither satisfies the presence check nor gets patched by prepare_for_emit.
  • Every field’s declared sfXxx still has its serialized type checked against its kind, a scalar or nested array directly inside an array is a compile error, and an emit_details field inside any container is a compile error (it’s only meaningful once, at the top, last).

See examples/21_txn-template-nested for the worked example — a Remit whose sfAmounts is a homogeneous, indexed array of compile-time-baked issued entries, filled through the amounts(i) accessor (two entries, written one after the other rather than from a loop) and emitted through the same lifecycle as 10_emit-txn’s Payment.

Optional and variable-length fields

xahaud’s STObject/STArray deserializer skips a single 0x99 byte (“NOP”) wherever it expects the next field’s header, on the emit path only (never sto_*) — a present-or-absent field, or one of a runtime-chosen length, can therefore keep a compile-time-fixed byte offset: absence, or the unused tail of a variable-length slot, is just more NOPs. txn_template! builds on this directly, so these fields need no StoWriter:

  • optional <kind>(sfX) — any fixed-width kind, present or absent.
  • any_amount(sfX) / optional any_amount(sfX) — one 49-byte slot holding either the 8-byte native form (the rest NOP-filled) or the full 48-byte issued form.
  • vl(sfX, MIN, MAX) / optional vl(sfX, MIN, MAX) — a VL blob whose length is chosen at runtime within [MIN, MAX], in a slot sized for MAX.
  • optional object(sfX) { .. } / optional array(sfX) [ .. ] — a whole nested container, present or absent, with no view type: its own fields are plain set_x_<field> methods on the parent, and any of them makes the container present.
  • array(sfX) [ Elem: optional object(sfY) { .. } ; N ] — a homogeneous, indexed array (see above) whose elements are individually present-or-absent, the array itself always N slots long.

Every kind above that can infer from sfX’s own serialized type (the same inference the required kinds above use for a bare field: sfX) has a bare-sfX optional twin too: optional sfX, optional sfX { .. }/ [ .. ], and a homogeneous array’s Elem: optional sfY { .. } — same budget, same generated API, only the spelling differs; a kind that cannot infer (Amount, VL, Issue) still needs its explicit optional form. An array’s own element (the homogeneous Elem: ..; N form aside) is numbered by its zero-based position among every element in the list — [ sfY { .. }, optional sfY { .. } ] reaches its elements as _0, _1.

Every container — the top level, each object/array, each homogeneous array, each named optional object/optional array — has its own 63-NOP budget: the worst case over its direct optional/variable children (every optional field absent, every vl at MIN, every any_amount native) must fit, checked at compile time with a message naming the container and the budget. This is why a hook author sometimes has to nest a field into its own small container, or choose a per-element array over a homogeneous one, rather than declare fields alongside each other freely: examples/22_txn-template-optional’s Remit::amounts is an array with one required and one optional element, both numbered by position (sfAmountEntry { amount: sfAmount = AnyAmount() }, optional sfAmountEntry { .. }) — it can hold what two fully optional 52-byte sfAmountEntry elements (2 * 52 = 104 > 63) could not, since a required element charges its container nothing (Elem::LEN, not Elem::LEN reserved-but-optional) while only the second (optional) element’s own 52 bytes count against the array’s budget.

The generated API mirrors fixed_vl/homogeneous-element setters: set_x writes the field (making it present), clear_x restores the NOP-filled default; a named optional object/optional array gets no view type at all — its own fields flatten onto the parent like a plain nested container’s, and any one of them (or a generated enable_x(&mut self)) first materializes it and every enclosing optional ancestor, if absent, before writing; clear_x(&mut self)/is_x_present(&self) -> bool round it out. A homogeneous array whose element is itself optional keeps its element view type (the array is indexed at runtime), and that view’s own setters gained the same auto-presenting behavior, alongside its existing enable()/clear()/is_present() (Elem::enable()/Elem::clear() — see crates/rshooks/src/txn.rs’s mod tests and crates/rshooks/tests/ui/pass/txn_template_optional.rs for a worked example; examples/22_txn-template-optional uses the named-array form instead). From 22_txn-template-optional:

if let Ok(Some(tag)) = self.hook_param.dest_tag.get() {
    txn.set_destination_tag(u32::from_be_bytes(tag));   // optional sfDestinationTag
}
if let Ok(Some(bytes)) = self.hook_param.amt2.get() {
    // optional sfAmountEntry { .. } (unnamed, position 1) -- this setter
    // makes it present.
    txn.set_amounts_1_amount_native(u64::from_be_bytes(bytes))?;
}

NOP-padded bytes must never reach the sto_* family (sto_validate/sto_subfield/sto_subarray/sto_emplace/sto_erase): the Hook API’s own lightweight parser rejects STI_NUMBER (the NOP’s type) outright, unlike xahaud’s own deserializer on the emit path. No rshooks code hands sto_* a NOP-padded region today; keep it that way in hook-side code too. See docs/NOP_PADDING_DESIGN.md for the full design, including the exact worst-case-NOP formula per kind.

Deferred kinds

Vector256 and PathSet — distinct wire shapes from a plain VL blob (Vector256 is a flat run of 32-byte hashes with no per-element header; PathSet is its own nested path/step grammar) — have no txn_template! kind yet; see docs/TXN_TEMPLATE_FIELDS_DESIGN.md §6 for what’s deferred and why. A field of one of these types still needs StoWriter (below) or hand-rolled bytes.

prepare_for_emit()

Because those seven fields are mandatory, every txn_template! invocation that compiles gets a prepare_for_emit(&mut self) -> Result<Prepared<'_, Self>> for free. It:

  1. Reads the current ledger sequence and writes FirstLedgerSequence = ledger_seq + 1, LastLedgerSequence = FirstLedgerSequence + 4.
  2. Writes Account from hook_account().
  3. Calls etxn_details() into the reserved EmitDetails region and uses its returned length — not the region’s max capacity, since the real serialized size is 116 bytes without a #[cbak] export or 138 bytes with one.
  4. Slices the template to exactly emit_details offset + returned length, calls etxn_fee_base() over that real slice, and writes Fee.
  5. Returns a Prepared<'_, Self> wrapping both the template and the real blob length.

prepare_for_emit overwrites whatever FirstLedgerSequence, LastLedgerSequence, Fee, and Account were previously set to — their setters exist, but any value written through them before calling prepare_for_emit is discarded. Sequence and SigningPubKey are never touched at runtime; their baked defaults (0, and the empty VL marker) are already correct.

The unprepared template type has no as_bytes/emit method of its own — only Prepared does. That’s the compile-time fix for the obvious footgun: code that tries to read out an emit-sized blob whose plumbing fields were never actually filled simply fails to compile.

let Ok(prepared) = txn.prepare_for_emit() else {
    rollback!(b"emit-txn: prepare_for_emit failed", EmitTxnError::PrepareFailed)
};

match prepared.emit() {
    Ok(_hash) => accept!(b"emit-txn: emitted", 0),
    Err(_) => rollback!(b"emit-txn: emit failed", EmitTxnError::EmitFailed),
}

Prepared::emit() is a convenience wrapper over rshooks::api::etxn::emit_buf that passes exactly Prepared::as_bytes() — the real, emit-sized prefix of the template’s buffer, never the full reserved capacity.

The statics idiom for the template buffer

A txn_template! type is meant to live in a static, not a stack local — this is the same reasoning Guards and Loops covers for large buffers in general: a static’s bytes land in a wasm data segment (pure data, no runtime store instructions), while materializing the same bytes into a stack local at runtime costs real, guard-relevant instructions.

static TXN: HookStatic<Payment> = HookStatic::new(Payment::new());
let Some(txn) = TXN.take() else {
    rollback!(b"emit-txn: static buffer already taken", EmitTxnError::BufferAlreadyTaken);
};

HookStatic::new is const, so Payment::new()’s baked-in headers and defaults land in the data segment directly. take() hands out the buffer’s one exclusive &'static mut on the first call and None on every call after — sound with no unsafe because a hook runs single-threaded in a freshly instantiated wasm module per invocation, so “handed out at most once” really does mean at most once, ever.

#[cbak(<index>)]: reacting to the outcome

A Hook entry can optionally pair its #[hook(<index>, ...)] with a #[cbak(<index>)] at the same index, exporting cbak for that entry’s own build. The host invokes it later — in a separate execution — when a transaction this hook previously emitted settles on ledger, whether it succeeds or bounces:

#[hooks(description = "Emits a Payment and handles its callback.")]
pub struct EmitTxn;

#[hooks]
impl EmitTxn {
    #[hook(0, name = "emit-tx", on = [Invoke], can_emit = [Payment])]
    fn main(&self) -> HookResult { /* ... */ }

    #[cbak(0)]
    fn cbak(&self, outcome: EmitOutcome) -> HookResult {
        match outcome {
            EmitOutcome::Applied => Ok(Accept::new(b"emit-txn: applied", 0)),
            EmitOutcome::EmitFailure => Ok(Accept::new(b"emit-txn: emit failure", 1)),
        }
    }
}

(from examples/10_emit-txn; a real callback typically also inspects the settled transaction’s metadata via SlotObject::from_meta() — see Slots and Ledger Objects — once it knows the emission actually applied.) #[cbak(<index>)] takes only the index — no name/ on/etc. of its own, since it settles for whatever its paired #[hook] at that same index emitted. Its fn may declare one argument after &self — EmitOutcome (or a raw u32) — populated from the host’s cbak(u32) argument. Declaring a #[cbak] changes that entry’s EmitDetails real serialized size (138 bytes instead of 116), which is exactly why prepare_for_emit reads etxn_details’s returned length rather than assuming a fixed one.

The EmitFailure trap

An emission is not guaranteed to apply: if its LastLedgerSequence passes before it settles, xahaud never applies it as its own transaction type. Instead it applies a ttEMIT_FAILURE pseudo-transaction carrying sfLedgerSequence, sfTransactionHash (the emitted transaction’s hash), and the original sfEmitDetails. That pseudo-transaction — not the emitted transaction — is the callback’s own originating transaction, and its metadata reports tesSUCCESS regardless of what the real emission would have done. Reading meta_slot → sfTransactionResult without checking the outcome first therefore reports success for an emission that never delivered. Gate on EmitOutcome::Applied before trusting meta_slot (equivalently, otxn_type() != TxType::EmitFailure), and in the EmitFailure arm read sfTransactionHash to learn which emission expired.

can_emit on the entry attribute

Emitting a transaction of a given type is itself a capability a Hook entry must declare. #[hook(<index>, ...)]’s can_emit list names every transaction type this entry’s wasm might emit:

#[hook(0, name = "emit-tx", on = [Invoke], can_emit = [Payment])]
fn main(&self) -> HookResult { /* ... */ }

rshooks build cross-checks a declared can_emit against whether the compiled entry’s wasm actually calls emit — a mismatch either way (a declared type never emitted, or an emit with no matching declaration) surfaces as a build-time warning, never a hard error. See Per-Hook Attributes for the full attribute reference, including the three-state semantics of an omitted vs. explicitly empty can_emit, and how it interacts with on and the other per-entry attributes.

Runtime-shaped transactions: StoWriter

txn_template! covers fixed-shape nested containers directly (see “Nested STObject/STArray” above), including present-or-absent fields and variable-length blobs within a fixed MAX (see “Optional and variable-length fields” above) — what it cannot describe is a runtime element count: an sfAmounts whose entry count isn’t fixed by the declaration (examples/17_sto-writer’s Remit, one entry per destination, depending on what the invoking transaction’s hook parameters supply), or any shape needing more than one array’s 63-NOP budget can hold. That case needs rshooks::sto_writer::StoWriter instead: a bounded, allocation-free cursor over caller-owned storage with its own prepare_for_emit()/Prepared::emit() lifecycle, built directly on top of the same Prepared type this page’s prepare_for_emit() returns. See The StoWriter API.

The StoWriter API

Emitting Transactions covers txn_template!: a declarative macro that bakes a transaction’s field offsets and total length into a const fn, computed entirely at compile time — including a fixed-shape nested STObject/STArray, such as a two-entry sfAmounts whose element count and shapes are known at declaration time (see Emitting Transactions), and, via its NOP-padded kinds, a present-or-absent field or a runtime-chosen-length blob within a fixed MAX (see Optional and variable-length fields). What txn_template! cannot describe is a runtime element count: Remit’s sfAmounts, one sfAmountEntry per destination, whose count depends on what the invoking transaction’s hook parameters supply — more entries than any one array’s 63-NOP budget could hold present-or-absent. rshooks::sto_writer::StoWriter is the runtime counterpart for that case: a bounded, allocation-free cursor over caller-owned storage that writes field headers, tracks open containers, and checks every write against the buffer’s real bounds. This page walks through it end to end using examples/17_sto-writer’s Remit hook as the worked example throughout — the same emission lifecycle Emitting Transactions covers (reserve, build, emit, react), with StoWriter standing in for txn_template! at the “build” step.

Field order is caller-supplied, not canonical

StoWriter writes fields in exactly the order its methods are called and never reorders or validates that order. xahaud accepts a serialized object’s fields in any order and always re-serializes sorted by field code, so the on-ledger transaction is canonically ordered regardless of write order — writing fields outside ascending (type, field) order changes nothing about validity or etxn_fee_base (the serialized size is the same either way; only in-buffer field position differs). What is enforced: every write is checked against the buffer’s real bounds with overflow-checked cursor arithmetic; begin_object/begin_array and end_object/end_array must match (an STArray’s direct children may only be opened with begin_object — a bare scalar or nested array directly inside an open array is rejected); nesting is bounded by STO_WRITER_MAX_DEPTH (10); and no write succeeds once prepare_for_emit has finalized the writer.

Building a transaction

StoWriter::new(buf) wraps caller-owned storage as a fresh writer, empty, at the top-level container. StoWriter::resume(buf, prefix_len) instead starts the cursor at prefix_len, trusting the caller that buf[..prefix_len] already holds a valid serialized prefix — typically one baked at compile time into a static, so it costs a wasm data segment instead of runtime instructions. A resumed writer carries no emit-plumbing bookkeeping for that prefix until emit_plumbing(offsets) supplies it: a PlumbingOffsets naming where the four patchable fields (sfFirstLedgerSequence, sfLastLedgerSequence, sfFee, sfAccount) live, standing in for the field-by-field calls that would otherwise have recorded those offsets themselves. examples/17_sto-writer uses this to bake its entire emit-plumbing prefix at compile time — see the updated build_remit below.

Scalar fields have one method per STI_* shape:

methodwrites
u16_field(f, value)an STI_UINT16 field (e.g. sfTransactionType)
u32_field(f, value)an STI_UINT32 field (e.g. sfFlags, sfSequence)
account_id(f, &value)an STI_ACCOUNT field (a 1-byte VL length of 20, then the 20 raw bytes)
empty_vl(f)an STI_VL field as an empty blob (a 1-byte zero-length marker) — what SigningPubKey looks like on an emitted transaction
vl(f, value)an STI_VL field with a caller-supplied length prefix and payload — rippled’s 1/2/3-byte VL length encoding, sized to value.len()
native_amount(f, drops)an STI_AMOUNT field encoded as a native (XRP/XAH) amount
iou_amount(f, xfl, &currency, &issuer)an STI_AMOUNT field encoded as an issued amount, via the float_sto host call

iou_amount is the runtime counterpart of txn_template!’s amount kind (see Emitting Transactions) — same 48-byte issued layout, written through a host call here instead of baked in at compile time. Likewise, vl is the runtime counterpart of txn_template!’s fixed_vl(sfX, N) kind: the same length-prefix encoding, computed from a runtime value.len() here instead of baked in for a compile-time-fixed N. vl is the one writer whose payload length the caller controls: pass a &[u8; N] (or another length the optimizer can see as a constant at the call site) so the copy stays a fixed-size store after inlining — a genuinely runtime-sized slice can lower to a compiler-generated copy loop, which rshooks build’s guard checker rejects. empty_vl(f) is exactly vl(f, &[]) plus the SigningPubKey plumbing bookkeeping vl does not do — prefer empty_vl there.

Containers nest with begin_object(f)/end_object() (an STObject field, e.g. sfAmountEntry) and begin_array(f)/end_array() (an STArray field, e.g. sfAmounts) — legal directly inside an STObject; an STArray’s direct children may only be begin_object, never a bare scalar or a nested array. as_bytes()/len()/is_empty() read back what has been written so far at any point, including mid-construction with open containers — unlike Prepared::as_bytes, this needs neither the container stack closed nor any emit-plumbing field present.

examples/17_sto-writer’s build_remit builds a Remit with a native sfAmounts entry always, plus a second, issued-amount entry only when the hook’s CUR/ISSUER parameters are both present:

fn build_remit<'a>(
    buf: &'a mut [u8; BUF_LEN],
    destination: &AccountId,
    issued: Option<&(CurrencyCode, AccountId)>,
) -> Result<StoWriter<'a>> {
    let mut w = StoWriter::resume(buf, PREFIX_LEN)?;
    w.emit_plumbing(PREFIX.1)?;
    w.account_id(sfDestination, destination)?;

    w.begin_array(sfAmounts)?;
    w.begin_object(sfAmountEntry)?;
    w.native_amount(sfAmount, 1)?;
    w.end_object()?;
    if let Some((currency, issuer)) = issued {
        w.begin_object(sfAmountEntry)?;
        w.iou_amount(sfAmount, XFL::one(), currency, issuer)?;
        w.end_object()?;
    }
    w.end_array()?;

    Ok(w)
}

(from examples/17_sto-writer.) The field order here — TransactionType, Flags, Sequence, …, Amounts — reads naturally top-to-bottom, but nothing about StoWriter requires it; see “Field order” above.

Required fields and duplicate rejection

StoWriter detects the same six required emit-plumbing fields txn_template! does — sfSequence, sfFirstLedgerSequence, sfLastLedgerSequence, sfFee, sfSigningPubKey, sfAccount — by value, as they are written, recording an offset (or a presence flag, for Sequence/SigningPubKey) for prepare_for_emit to patch or verify later. Because FirstLedgerSequence/LastLedgerSequence/Account/Fee are patched at each field’s recorded offset, a second write of any of these six fields would leave the first occurrence unpatched or duplicated in the emitted blob — a serialized object cannot repeat a field — so a repeat write is rejected with HookError::AlreadySet. Any other field may be written more than once as far as StoWriter is concerned; whether a repeated non-plumbing field is otherwise valid is between the caller and the host.

prepare_for_emit() and Prepared::emit()

There is no public emit_details method on StoWriter — unlike txn_template!’s generated type, which declares emit_details as a structural marker field in its field list, StoWriter::prepare_for_emit appends the runtime-sized sfEmitDetails field itself, at the current cursor, once every container the caller opened has been closed:

  1. Requires every container to be closed (depth == 0) and all six required fields to have been written; otherwise HookError::InvalidArgument.
  2. Patches FirstLedgerSequence/LastLedgerSequence from ledger_seq() + 1 / + 5 (i.e. FirstLedgerSequence + 4), the same values txn_template!’s prepare_for_emit computes.
  3. Patches Account from hook_account().
  4. Appends sfEmitDetails at the cursor via etxn_details, trusting its returned length (116 bytes without a #[cbak] export, 138 bytes with one) — buf must have at least EMIT_DETAILS_MAX_LEN (138) bytes of headroom beyond everything already written, or this step fails with HookError::InvalidArgument.
  5. Computes etxn_fee_base over the full serialized prefix, including the just-appended EmitDetails, and patches Fee.
  6. Finalizes the writer (every write after this point fails with HookError::InvalidArgument) and returns a Prepared<'_, StoWriter<'_>> handle sized to exactly what was written.

Sequence and SigningPubKey are left untouched (checked for presence only) — exactly as in txn_template!’s macro-generated prepare_for_emit. StoWriter::prepare_for_emit returns the same crate::txn::Prepared type txn_template!’s does, so Prepared::emit() — the thin wrapper over rshooks::api::etxn::emit_buf that passes exactly Prepared::as_bytes() — works identically either way:

let Ok(mut w) = build_remit(buf, &destination, issued.as_ref()) else {
    rollback!(b"sto-writer: build failed", StoWriterError::BuildFailed)
};

let Ok(prepared) = w.prepare_for_emit() else {
    rollback!(
        b"sto-writer: prepare_for_emit failed",
        StoWriterError::PrepareFailed
    )
};

match prepared.emit() {
    Ok(_hash) => accept!(b"sto-writer: emitted", 0),
    Err(_) => rollback!(b"sto-writer: emit failed", StoWriterError::EmitFailed),
}

(from examples/17_sto-writer.) As with txn_template!, etxn_reserve must already have been called before prepare_for_emit/emit — neither calls it for you.

The statics idiom, again

StoWriter’s backing buffer belongs in a static for the same reason Emitting Transactions gives for a txn_template! type: a HookStatic-held buffer lands in a wasm data segment/BSS instead of being materialized by runtime stores, which matters even more here given StoWriter’s buffers tend to be larger than a single fixed-shape template’s. examples/17_sto-writer follows the same pattern:

const BUF_LEN: usize = 285;
static BUF: HookStatic<[u8; BUF_LEN]> = HookStatic::new({
    let mut buf = [0u8; BUF_LEN];
    codec::write_const_bytes(&mut buf, 0, &PREFIX.0);
    buf
});
let Some(buf) = BUF.take() else {
    rollback!(
        b"sto-writer: static buffer already taken",
        StoWriterError::BufferAlreadyTaken
    );
};

Buffer sizing

BUF_LEN = 285 covers the fixed emit-plumbing prefix (95 bytes: TransactionType/Flags/Sequence/FirstLedgerSequence/ LastLedgerSequence/Fee/SigningPubKey/Account/Destination/ Amounts’ own header/AmountEntry’s own header/a native Amount/its ObjectEndMarker/the ArrayEndMarker), plus a second, issued-amount AmountEntry sized for the worst case — both hook parameters present (52 bytes: header + a 48-byte STAmount + ObjectEndMarker) — plus EMIT_DETAILS_MAX_LEN (138 bytes), the headroom prepare_for_emit requires beyond everything already written. Sizing a StoWriter buffer is manual in exactly this way — there is no macro to compute it, since the shape is runtime-dependent by design.

Unit tests

examples/17_sto-writer exercises the real StoWriterRemit entry through rshooks_testenv::TestEnv::invoke, in both layouts Off-Chain Unit Tests covers: tests/remit.rs and an in-crate #[cfg(test)] module. Both cover the full prepare_for_emit()/Prepared::emit() path with the sfAmounts array present — native-only and native-plus-issued shapes, the DEST-missing rollback, and cbak — and, in-crate only, build_remit/prepare_for_emit itself against a small local HostBackend mock for byte-level assertions (build_remit is private, so only an in-crate test can call it directly; see rshooks::raw::backend on The Raw Layer for what that mock hooks into).

Cost, here

examples/17_sto-writer/metrics.json records the main entry’s worst-case instruction count, size, and max nesting depth as built by rshooks build/check (examples/’s opt-level = 3 profile). It sits well inside the 65,535-instruction WCE ceiling and the 65,535-byte SetHook size limit, and above 10_emit-txn’s fixed-template Payment, which is expected: this hook does strictly more work at runtime (two hook-parameter reads, a conditional issued-amount branch, and StoWriter’s own bounds/duplicate checks on every field, versus a const fn-baked template with none of that at runtime).

The rshooks CLI

rshooks is a single binary with three subcommands: build (the one you’ll use for everyday work), clean (post-process an already-compiled wasm without invoking cargo), and check (validate any wasm file against the full SetHook rule set, without modifying it). This page is the complete flag reference for all three, taken directly from the CLI’s own definitions.

Every subcommand also accepts the standard clap-generated -h/--help; rshooks --version prints the installed version.

rshooks build

Builds a #[hooks] crate for wasm32v1-none: one discovery build to read its declarations, then one cargo rustc build per declared index, each cleaned and validated into its own SetHook-legal binary. This is the pipeline described in Building a Hook and Hook Chains.

rshooks build --manifest-path path/to/Cargo.toml
flagdefaultdescription
--manifest-path <PATH>cargo’s default (current directory)Path to the crate’s Cargo.toml, forwarded to every cargo invocation.
-p, --package <NAME>noneBuild only the named package, forwarded to cargo’s -p. Useful when --manifest-path points at a workspace.
--out <DIR>target/rshooks/<crate-name> under the workspace’s target directoryOutput root: generation directories (gen-<N>/) are written under it, with current symlinked to the latest complete, validated one.
--allow-oversizeoffWrite each index’s output even if it exceeds the 65,535-byte SetHook size limit. The result is still clearly marked invalid in the printed report.
--no-optimizeoffSkip the Binaryen wasm-opt -Oz size-optimization pass that otherwise runs on each entry’s raw wasm before cleaning.
--account <r...>noneFill the generated template’s Account placeholder with this address.
--namespace <64hex>noneFill the generated template’s HookNamespace placeholder(s) with this value.
--overrideoffAdd hsfOVERRIDE (Flags: 1) to every declared (non-gap) entry in the generated template, permitting replacement of an already-installed Hook at that position. Never applied to gap ({"Hook": {}}) entries.

On success, build writes, under <out-root>/current/: one <index>.<fn>.wasm and one <index>.<fn>.metadata.json per declared entry, plus sethook.template.json and sethook.template.meta.json covering the whole chain — see Per-Hook Attributes for the sidecar and template’s exact shape.

rshooks clean

Runs the same post-processing pipeline as build — the Binaryen wasm-opt -Oz pass, the cleaner (drops custom sections and any export other than hook/cbak, then garbage-collects), flatten (inlines every defined helper function into hook/cbak), unnest, and the authoritative guard check — on one already-compiled wasm file from any toolchain, without invoking cargo. Useful for post-processing a single artifact you already have on disk — for example one index’s raw build output from a different pipeline, or one you want to reprocess with different flags without rebuilding.

This makes clean usable as a post-processor for Hooks written in C and compiled with clang: C authors can write ordinary (non-inline) helper functions, and loops with GUARD inside them, exactly as they would in any other C program, and clean inlines those helpers into hook/cbak, so the type section reduces to the import types plus the entry-point type, as SetHook requires.

rshooks clean path/to/artifact.wasm
clang --target=wasm32 -mcpu=mvp -nostdlib -O2 \
  -Wl,--no-entry -Wl,--allow-undefined -Wl,--export=hook -Wl,--export=cbak \
  -o hook.raw.wasm hook.c
rshooks clean hook.raw.wasm -o hook.wasm

-mcpu=mvp keeps clang from emitting post-MVP instructions (such as sign-extension ops) in the first place. Without it, clean stops before the wasm-opt pass with an error naming the flag, because that pass only accepts modules within the WebAssembly MVP instruction set; with --no-optimize (or under check) such a module may still pass the authoritative upstream guard checker, but a divergence warning is printed, since the Rust validator enforces the MVP instruction set. Compile with -O2 or higher: at -O0/-O1 clang does not keep the _g call as the first instruction of every loop, so the raw output only passes the guard check when the wasm-opt pass is left on.

A helper containing a guarded loop is duplicated at each call site while keeping one guard id, so size its maxiter for the total across all call sites. See Guards and Loops.

flagdefaultdescription
input (positional)—The input wasm file. Required.
-o, --out <PATH><input>.clean.wasmWhere to write the cleaned binary.
--allow-oversizeoffWrite the output even if it exceeds the 65,535-byte SetHook limit.
--no-optimizeoffSkip the Binaryen wasm-opt -Oz size-optimization pass that otherwise runs on the raw wasm before cleaning.

clean does not generate a metadata sidecar or a SetHook template — those steps are specific to build, since they need the original crate’s #[hooks] carriers from cargo’s raw discovery artifact, and clean operates on a single already-processed wasm file with no such carrier left in it.

rshooks check

Validates a wasm file against the full SetHook rule set without modifying it. Like clean, this works on any wasm file, including ones not built by this toolchain at all — for example, a Hook compiled from C.

rshooks check path/to/hook.wasm
flagdefaultdescription
file (positional)—The wasm file to validate. Required.

On success, check prints the same worst-case-instruction and nesting-depth report as build/clean, followed by OK: <file> is a valid SetHook wasm binary and the size/fee estimate. On failure, it prints INVALID: <file> failed validation: with the specific reasons, and exits with a non-zero status — making it suitable for a CI gate on hand- written or third-party wasm as well as this toolchain’s own output.

Per-Hook Attributes and the SetHook Template

A #[hooks] chain declares its descriptive and SetHook-facing metadata directly on the struct and on each entry, rather than in a separate top-level block. rshooks build reads these declarations and turns them into a JSON sidecar per entry, plus one SetHook transaction template covering the whole chain. This page is the full grammar for both attribute forms and the exact shape of everything the build generates from them — Hook Chains covers the concepts (index, shared schema, the template’s patch semantics) this page assumes.

The struct-level attribute: #[hooks(description = "...")]

#[hooks(description = "20-seat L1/L2 governance and reward chain")]
pub struct Governance { /* ... */ }

description is the only argument #[hooks] accepts on a struct — optional, free-form text carried into every entry’s sidecar under its chain object (below). There is deliberately no struct-level name: a crate’s identity for tooling purposes is its Cargo package name, and an on-ledger HookName is a per-entry concern (below), since two entries in the same chain can be named differently, the same, or not at all.

The per-entry attribute: #[hook(<index>, ...)]

#[hook(0, name = "govern", on = [Invoke], can_emit = [Invoke, SetHook], description = "Governance state machine")]
fn govern(&self) -> HookResult { /* ... */ }
  • Leading positional argument, 0..=9 — required, no default. This entry’s index; see Hook Chains for what it means.
  • name = "..." — optional, this entry’s on-ledger HookName. See “HookName” below for the length rule.
  • on = ... / on_incoming + on_outgoing — optional; see “Trigger forms” below. Mutually exclusive with each other.
  • can_emit = [Tx, ...] — optional; see “The three states of can_emit” below.
  • description = "..." — optional, free-form text for this entry’s sidecar (independent of the struct’s own description).

#[cbak(<index>)] takes only the index — no other attribute arguments, since a callback doesn’t get its own trigger or emit declaration; it settles for whatever its paired #[hook] at the same index emitted.

Transaction type names

Every entry in on, on_incoming, on_outgoing, and can_emit is a bare TxType variant name — Payment, not TxType::Payment or ttPAYMENT. Because the macro resolves each name against the real enum, a misspelling is a compile error, not a silent no-op. Duplicate entries within one list are also rejected.

Names use Xahau’s canonical TransactionType spellings, including some that are easy to get wrong by guessing: SetHook, SetRegularKey, and AMMCreate. rshooks maintains the authoritative list of every valid name against the actual protocol transaction set.

Trigger forms

Each entry chooses one of four trigger forms:

formwire outputmeaning
omitted entirelyno trigger field at allNo installation override. For a brand-new HookDefinition, this means the protocol default: fires on every transaction type except SetHook, automatically tracking any type added to the protocol later. If this entry’s wasm already has an existing HookDefinition on-ledger (an Update, not a fresh Install), the existing definition’s trigger is inherited instead — so omission is not a portable guarantee of “fires on everything,” only “don’t say.”
on = allan explicit all-zero HookOn mask (every ordinary bit clear, the SetHook bit set so it alone doesn’t fire)Guaranteed catch-all, tracking future transaction types the same way omission’s new-definition case does, but without depending on whether this is an Install or an Update.
on = [Payment, Invoke, ...]a HookOn mask covering exactly the listed typesFires only for the listed types. on = [] is legal and means “never fires.”
on_incoming = [..] + on_outgoing = [..]HookOnIncoming + HookOnOutgoing (mutually exclusive with HookOn)Direction-sensitive triggering (HookOnV2). Must be declared as a pair — one without the other is a build error. If both sets would end up identical, the build rejects it and asks for plain on instead, since that’s what it means.

Pick on = all when you need a guaranteed, future-proof catch-all regardless of Install/Update history; pick omission only when “whatever this position already has, if anything” is genuinely what you mean.

The three states of can_emit

can_emit has the same “omitted vs. explicitly empty vs. a list” three-state shape as on, and it matters just as much here — an omitted can_emit is not the same as an empty one:

declarationwire outputmeaning
omittedno HookCanEmit fieldNo installation override — inherits an existing definition’s emit permissions on Update, or (for a fresh Install) no restriction at all.
can_emit = []an explicit deny-all HookCanEmit maskThis entry may emit nothing.
can_emit = [Payment, ...]a HookCanEmit allowlist maskThis entry may emit only the listed types.

rshooks build cross-checks each entry’s declared can_emit against whether its own compiled wasm actually calls emit (checked on the final, per-index-cleaned binary, so unreachable code in a shared crate never counts). Emitting without permission to, or declaring permission never used, both surface as build-time warnings — never a build failure — naming the specific mismatch. A #[cbak] declared for an entry that never actually calls emit gets the same warning treatment, in the other direction.

HookName

HookName is a Rust UTF-8 string. The macro itself enforces 2 through 8 Unicode scalar values — deliberately counting characters, not encoded bytes. This is a separate rule from xahaud’s own ledger-level requirement that a HookName be 4 through 16 UTF-8 bytes; a name intended for direct on-chain submission needs to satisfy both. Because these two rules can diverge for non-ASCII names, rshooks build checks the byte-length rule too and prints a warning (not a hard error) when a declared HookName doesn’t fit it. Two entries in the same chain sharing a name is legal protocol-wise, and produces an informational note rather than a warning or error.

How the declarations travel through the build

Both #[hooks] macros carry their declarations as compact JSON, hex- encoded into the names of dead wasm exports that are never actually called: the struct macro emits one (prefix __rshooks_chain_v2_, the shared schema), and the impl macro emits another (prefix __rshooks_hooks_v2_, every entry’s per-index metadata). rshooks build reads both from the discovery build’s raw artifact (see Building a Hook), then re-checks that each per-index build’s own carriers are byte-identical to discovery’s, before the ordinary hook-cleaner pass removes them along with every other non-hook/cbak export. These declarations are build-only and never change any deployed binary: they add no data segment, no runtime code, no import, and no byte to the final wasm.

The per-entry JSON sidecar

For Governance’s govern entry (index 0, on = [Invoke], can_emit = [Invoke, SetHook], name = "govern"), rshooks build writes out/current/0.govern.metadata.json:

{
  "index": 0,
  "hook_fn": "govern",
  "cbak_fn": null,
  "name": "govern",
  "description": "Governance state machine",
  "HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
  "HookCanEmit": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFF9FFFFD",
  "HookName": "676F7665726E",
  "HookHash": "…64 hex chars…",
  "WCE": { "hook": 27751, "cbak": 0 },
  "builder": {
    "name": "rshooks-build",
    "version": "0.2.3",
    "rustc": "rustc 1.89.0 (29483883e 2025-08-04)",
    "cargo_args": ["rustc", "--release", "--locked", "--target", "wasm32v1-none", "--crate-type", "cdylib"],
    "rustc_args": ["--cfg", "rshooks_entry=\"0\"", "--check-cfg", "cfg(rshooks_entry,values(\"0\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\"))", "-C", "link-arg=-zstack-size=131072"],
    "wasm_opt": true
  },
  "human": {
    "HookOn": ["Invoke"],
    "HookCanEmit": ["Invoke", "SetHook"],
    "HookName": "govern"
  },
  "chain": {
    "struct": "Governance",
    "description": "20-seat L1/L2 governance and reward chain",
    "decls": {
      "state": [
        { "field": "reward_rate", "kind": "const", "key": "b\"RR\"", "value": "XFL" }
      ],
      "hook_params": [],
      "otxn_params": []
    }
  }
}

Fields not covered already on this page:

  • index/hook_fn/cbak_fn — this entry’s declared position, its hook function’s name, and its cbak function’s name (null if this index declares none).
  • Top-level HookOn/HookOnIncoming/HookOnOutgoing/HookCanEmit — the raw, deployable SetHook value: a 32-byte hex string encoding Xahau’s transaction-type bitmask. null when the corresponding attribute was omitted (never for an explicitly empty on = []/can_emit = [], which still produce a real mask).
  • HookHash — the uppercase hex of the first 32 bytes of this index’s own final cleaned wasm’s SHA-512 digest — identifies this one entry’s code, independent of chain position or which account installs it.
  • human — the readable, source-level form of every masked/hex field above. Use human to review what an entry declares; use the top-level fields when constructing an actual SetHook transaction.
  • sig_params — this entry’s declared signature parameters (Hook and Transaction Parameters), in wire-index order. Present only when the hook crate is built with the unstable-param-sig-interface feature — absent otherwise (as for govern here). When present, it is a null-free array, empty for an entry with no signature-parameter fn arguments. Each element is { "field", "type_byte", "name_hex" } — the argument’s own identifier, its type code (an XAS-010d type code), and the full declared HookParameterName as uppercase hex, the same value the generated HookParameters declaration entries below use verbatim.
  • builder.cargo_args/builder.rustc_args/builder.wasm_opt — the reproducibility record: the machine-independent cargo arguments and the verbatim rustc arguments (after --) this entry was built with, plus whether the wasm-opt -Oz pipeline pass ran. The -zstack-size link argument is the memory layout: a 2-page stack, with wasm-ld sizing linear memory to what the stack plus data/bss need.
  • chain — this crate’s shared schema, transcribed identically into every entry’s sidecar (not filtered down to what this one entry actually uses — see Hook Chains for why “declared” and “used by this entry” are deliberately different things here). decls lists every #[state]/#[hook_param]/ #[otxn_param] field on the struct, however many entries reference it.

The SetHook template and its generation sidecar

Once every declared index has built and validated successfully, rshooks build writes sethook.template.json:

{
  "TransactionType": "SetHook",
  "Account": "<ACCOUNT>",
  "Hooks": [
    {
      "Hook": {
        "CreateCode": "…hex of 0.govern.wasm…",
        "HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
        "HookCanEmit": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFF9FFFFD",
        "HookNamespace": "<NAMESPACE>",
        "HookApiVersion": 0,
        "HookName": "676F7665726E"
      }
    },
    {
      "Hook": {
        "CreateCode": "…hex of 1.reward.wasm…",
        "HookOn": "…",
        "HookCanEmit": "…",
        "HookNamespace": "<NAMESPACE>",
        "HookApiVersion": 0,
        "HookName": "7265776172D"
      }
    }
  ]
}

and sethook.template.meta.json:

{
  "crate": "governance",
  "version": "0.2.3",
  "generated_at": "2026-08-18T09:00:00Z",
  "hook_hashes": { "0": "…", "1": "…" },
  "positions": { "declared": [0, 1], "gaps": [], "untouched_beyond": 2 },
  "required_amendments": ["Hooks", "NamedHooks", "HookCanEmit"]
}

Generation rules, precisely:

  • Each declared index’s object key order is fixed: CreateCode, then HookOn (or the HookOnIncoming/HookOnOutgoing pair — whichever this entry declared, omitted entirely if this entry omitted its trigger), HookCanEmit (omitted if this entry omitted can_emit; present, possibly deny-all, otherwise), HookNamespace, HookApiVersion (always 0 — chains are Guard-type only), HookParameters (only if this entry declares signature parameters — see below), HookName (only if this entry declared one), and Flags (only under --override, value 1 (hsfOVERRIDE), and only on declared, non-gap entries).

  • A gap position is written as exactly {"Hook": {}} — no keys at all, ever, since adding any (including Flags) turns the no-op into a real operation. See Hook Chains for what that no-op does and doesn’t guarantee.

  • Account/HookNamespace are the literal placeholder strings shown above unless --account <r...>/--namespace <64hex> were passed at build time.

  • HookParameters (installed parameter values) are never generated for an ordinary #[hook_param(...)]/#[otxn_param(...)] field — a hook parameter’s install-time value has no fixed representation in source (default = ... is a runtime fallback expression, not an encodable constant; see Hook and Transaction Parameters). Add a HookParameters entry to the template by hand if a position needs one of those installed.

  • The one exception: an entry with signature-parameter fn arguments (the Hook Parameter Signature Interface, Hook and Transaction Parameters — requires the unstable-param-sig-interface feature) does get a generated HookParameters block — one declaration entry per declared argument, in index order, each with HookParameterValue = "00" (the interface’s own placeholder for “this parameter exists, at this index, with this type” — not an installed value). For examples/19_param-signature’s increment(account: AccountID, count: UInt16):

    "HookParameters": [
      {
        "HookParameter": {
          "HookParameterName": "5F5053000008076163636F756E74",
          "HookParameterValue": "00"
        }
      },
      {
        "HookParameter": {
          "HookParameterName": "5F505300010105636F756E74",
          "HookParameterValue": "00"
        }
      }
    ]
    

    An entry with no signature-parameter arguments omits the key entirely, exactly like before this feature — this is additive, not a change to the general rule above.

  • A second, independent exception: a chain declaring state interface fields (the Hook State Interface, Hook State — requires the unstable-state-interface feature) gets one declaration entry per #[state_interface(..)] field on every non-gap entry — state is chain-level, shared by every entry, so the declarations aren’t tied to one particular entry the way signature parameters are. They’re appended after that entry’s own signature-parameter declarations, if any. Unlike a signature-parameter declaration, HookParameterValue here carries the real value schema, not a "00" placeholder — see the book section linked above for the exact wire format. For examples/20_state-interface’s balances(id=0)/config(id=1):

    "HookParameters": [
      {
        "HookParameter": {
          "HookParameterName": "5F534900000208076163636F756E740205746F6B656E",
          "HookParameterValue": "020306616D6F756E74020775706461746564"
        }
      },
      {
        "HookParameter": {
          "HookParameterName": "5F5349000100",
          "HookParameterValue": "011006706175736564"
        }
      }
    ]
    

    A chain with no #[state_interface(..)] fields at all gets none of this — again additive, never a change to the general rule.

  • sethook.template.meta.json is generation provenance, not part of the transaction to submit: hook_hashes map index to HookHash; positions records which indices are declared, which are gaps within that range, and how far past the highest declared index the account’s own chain is left untouched; required_amendments always includes Hooks and adds NamedHooks/HookOnV2/HookCanEmit only when the template actually used the corresponding feature — it does not attempt to infer amendment requirements from what the wasm’s own Hook API calls need.

See The rshooks CLI for the --account/--namespace/ --override flags themselves, and Hook Chains for the conceptual model (owned-position patch, fail-closed by default, generation directories) this page’s JSON implements.

Off-Chain Unit Tests

Every other page in this book runs against a real Hook API — either the live standalone node the end-to-end suite (docs/E2E-TESTING.md at the repository root) deploys to, or, implicitly, the wasm host your Hook eventually ships to. This page covers a third option: rshooks-testenv, a mock host that runs a #[hooks] chain entry as plain native Rust under cargo test — no wasm build, no Docker, no node — with assertions on state changes, accept/rollback exits, and emitted transactions.

Positioning: not a fidelity oracle

cargo test against rshooks-testenv answers one question fast: is my hook’s logic right, in milliseconds, iterating faster than a wasm build lets you. It is not a substitute for the end-to-end suite. Ledger objects/keylets/slots, XFL float operations, and signature verification are all in scope and modeled (see Hook API coverage below); fee/reserve economics (explicit approximations), real instruction metering, guard enforcement, HookOn chain routing/execution, and consensus stay out of scope — see What this harness does not model below for the complete, honest list. Treat the two suites as complementary: reach for rshooks-testenv while you’re writing and refactoring hook logic, and rely on the end-to-end suite to confirm the compiled artifact behaves the same way on a real ledger.

Setup

rshooks-testenv is a separate crate; both it and rshooks’s testenv feature belong in [dev-dependencies] — a hook crate ships no_std with neither of them linked into its wasm artifact.

[lib]
crate-type = ["cdylib", "rlib"]   # rlib only needed for tests/ integration tests

[dependencies]
rshooks = { version = "0.2.3", features = ["host-panic-handler"] }

[dev-dependencies]
rshooks = { version = "0.2.3", features = ["testenv"] }
rshooks-testenv = "0.2.3"

Declaring rshooks twice — once in [dependencies], once in [dev-dependencies] with a different feature list — is intentional, not a mistake: Cargo unifies the two into one feature set (host-panic-handler + testenv, plus rshooks’s default panic-handler) for cargo test builds, while a plain cargo build/cargo rustc --crate-type cdylib (what rshooks build actually runs) never activates dev-dependencies at all, so testenv never reaches the shipped wasm. crate-type = ["cdylib", "rlib"] is only needed if you write tests in a separate tests/ directory (below); an in-crate #[cfg(test)] module needs neither the extra crate-type nor a separate pub export, just the chain struct itself being pub (which every #[hooks] struct in this book already is).

Two layouts

rshooks-testenv supports either style, and a crate can use both at once:

  • tests/*.rs integration tests treat the hook crate as an ordinary library dependency (use my_hook::MyChain;). This needs the rlib crate-type addition above, but the crate’s #![no_std] attribute is untouched — the library itself never changes.
  • An in-crate #[cfg(test)] mod tests module lives directly in src/lib.rs. Because #![no_std] and a std-using test module can’t coexist unconditionally, the attribute becomes #![cfg_attr(not(test), no_std)]: no_std stays in force for every real build (including the wasm build — cfg(test) is never set there), and only the cargo test compilation of the crate itself switches it off. A #[cfg(test)] module contributes nothing to any non-test build, wasm included, so this changes nothing about what ships.

examples/02_state-counter uses the tests/ layout (examples/02_state-counter/tests/counter.rs); examples/10_emit-txn demonstrates both side by side — examples/10_emit-txn/tests/emit.rs and an in-crate module at the bottom of examples/10_emit-txn/src/lib.rs. Both examples’ README.md show the exact cargo test invocation. examples/16_typed-results/tests/deposit.rs uses the tests/ layout too, and additionally asserts that a typed entry’s ?-propagated hook_errors! msg-clause message arrives byte-for-byte in HookExit.msg on the rollback path — the same exit.msg assertion shown below, just fed by Err(Rollback::new(..)) instead of a hand-written rollback!(msg, code) call.

A worked example

This is examples/02_state-counter reduced to its #[hooks] declaration — unchanged from every other page in this book:

#[hooks]
pub struct StateCounter {
    #[state(key = b"counter")]
    counter: State<u64>,
}

#[hooks]
impl StateCounter {
    #[hook(0, on = [Invoke])]
    fn main(&self) -> HookResult {
        let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);
        let next = count.wrapping_add(1);
        if self.state.counter.set(&next).is_err() {
            rollback!(b"state-counter: state_set failed", StateCounterError::StateSetFailed);
        }
        Ok(Accept::new(b"state-counter: incremented", next as i64))
    }
}

and the real test file that drives it, examples/02_state-counter/tests/counter.rs:

use rshooks_testenv::prelude::*;
use state_counter::{StateCounter, StateCounterError};

fn env() -> TestEnv {
    TestEnv::new()
        .hook_account([1u8; 20])
        .otxn(Otxn::new(TxType::Invoke).account([2u8; 20]))
}

#[test]
fn first_invoke_counts_to_one() {
    let env = env();
    let exit = env.invoke::<StateCounter>(0);
    assert_eq!(exit.exit, ExitType::Accept);
    assert_eq!(exit.code, 1);
    assert_eq!(env.state_typed::<u64>(b"counter"), Some(1));
}

#[test]
fn counter_persists_across_invocations() {
    let env = env();
    env.invoke::<StateCounter>(0);
    env.invoke::<StateCounter>(0);
    assert_eq!(env.state_typed::<u64>(b"counter"), Some(2));
}

#[test]
fn state_set_failure_rolls_back_without_persisting() {
    // Cap the value size below the 8-byte `u64` write this hook always
    // attempts, forcing `state_set` to fail so the hook's own rollback
    // path runs.
    let env = env().max_state_value_len(4);
    let exit = env.invoke::<StateCounter>(0);
    assert_eq!(exit.exit, ExitType::Rollback);
    assert_eq!(exit.code, StateCounterError::StateSetFailed.code());
    assert_eq!(env.state_typed::<u64>(b"counter"), None);
}

rshooks_testenv::prelude::* pulls in TestEnv, HookExit/ExitType, Otxn, Grant, EmittedTxn/EmitAttempt/TraceLine, and rshooks::decl::HookChainEntries — the trait invoke’s type parameter is bound by, implemented automatically on every #[hooks] chain struct on a non-wasm target. env() builds a fresh TestEnv per test (it consumes and returns self, so every builder call before the first invoke is a plain chain), and invoke::<StateCounter>(0) runs the entry declared #[hook(0, ...)] directly, by index — see Direct-entry invocation below for what “directly” means. The third test shows the general technique for forcing a hook’s own failure branch deterministically: override a TestEnv world limit (here, max_state_value_len) so the exact Hook API call the hook makes fails, rather than trying to construct byte-level input that happens to trigger it.

Assertions API tour

Every accessor below is a method on TestEnv, taking &self — World is interior-mutable, so you never need a mut binding, even across multiple invoke calls on the same env.

  • state(key) -> Option<Vec<u8>> / state_typed::<T>(key) -> Option<T> read this hook’s own state (own account, own namespace, or a namespace set via TestEnv::own_namespace). None covers both “no entry” and “the key itself is malformed” for state; state_typed additionally panics if the entry is present but fails to decode as T — a decode failure at assertion time is a test-author bug (the wrong value type), not something worth silently reporting as absence.
  • emitted() -> Vec<EmittedTxn> is every transaction this env has committed via accept!, cumulative across every invoke call so far. EmittedTxn::blob() gives the raw bytes for byte-level assertions, EmittedTxn::hash() the hash emit returned, and EmittedTxn::tx_type() the decoded TxType (see Emitting Transactions for the shape a txn_template!-built blob actually has).
  • emit_attempts() -> Vec<EmitAttempt> is every emit call, successful or not, cumulative — including attempts made during an invocation that ultimately rolled back. EmitAttempt::outcome is Ok(()) (also present in emitted()) or Err(EmitFailureReason) (NoReserve, ReserveExceeded, or InvalidBlob).
  • traces() -> Vec<TraceLine> is every trace/trace_num call this env has seen, cumulative, captured rather than printed — inspect it explicitly in a test instead of scrolling terminal output.
  • hook_again_requested() -> bool reflects whether the most recently committed invoke/invoke_cbak call requested to run again (via hook_again). A rolled-back or merely-returning invocation never commits, so it leaves this accessor exactly as it was before that invocation ran — it is not reset to false: if an earlier accepted invocation had set it true, a later rolled-back invocation (whether or not that one itself called hook_again) still reads true afterward.
  • skip_directives() -> Vec<([u8; 32], u32)> is every hook_skip(hash, flags) call from every accepted invocation so far, verbatim, in call order (flags == 0 add, flags == 1 delete) — this harness has no chain model, so nothing actually acts on a skip directive; it exists purely for asserting a hook called hook_skip with the arguments you expect.

Exit types

invoke returns a HookExit { exit: ExitType, code: i64, msg: Vec<u8> }. HookExit::is_success() is true only for ExitType::Accept:

ExitTypeWhere it comes fromWorld effectis_success()
Acceptaccept!(msg, code)state writes and this invocation’s validated emissions are committedtrue
Rollbackrollback!(msg, code)this invocation’s state snapshot is restored, its emissions discardedfalse
Returna bare return code (no accept!/rollback!)provisionally treated like Rollback (snapshot restored)false

ExitType::Return’s mapping is explicitly marked provisional in the harness’s own doc comments: no live-node evidence yet pins the real on-chain commit semantics of a bare return from a Hook entry (xahaud’s own ExitType internally is UNSET/WASM_ERROR/ROLLBACK/ACCEPT, with no documented committed semantics for a plain return). The harness picks the conservative reading — a test can’t pass on a state write that production might silently discard — and a differential end-to-end test exists specifically to pin the real behavior; once it lands, this table (and the harness’s mapping, if it turns out to need one) will be updated to match. Until then, don’t write a hook that relies on a bare return committing anything.

World builders

Every builder below is on TestEnv, consumes self, and returns Self — call them before the first invoke. Everything they set is part of the persistent world and survives across every invoke call on the same TestEnv; only what a single invocation itself does (state modification count, emit reserve, nonce budget, and so on) resets per call.

  • hook_account(acc) — this hook’s own account, read back by hook_account().
  • hook_hash(hook_no, hash) — the hash of the hook installed at chain position hook_no, read back by hook_hash(hook_no).
  • hook_pos(pos) — this hook’s own position in its chain, read back by hook_pos(). Chains aren’t auto-executed in Phase 1 (every invoke runs exactly one entry), but a chain is still representable this way — useful together with hook_hash/grant for testing foreign-write authorization logic that depends on which hook is currently running.
  • hook_param(name, value) — a Hook API parameter attached to this hook, read back by hook_param(name).
  • otxn(Otxn) — the originating transaction every invoke call sees. Otxn::new(tx_type) starts one with every field absent; chain .account(acc), .destination(acc), .amount_drops(drops), .param(name, value), .id(hash), or the general escape hatch .field_raw(sfield, bytes) for any sfXxx code not covered by a dedicated method. Every field is stored as its raw value bytes — what otxn_field would actually write into a caller buffer, no STObject header, no VL length prefix.
  • otxn_emitted(burden, generation) — marks the seeded otxn as itself an emitted transaction (seeds otxn_burden/otxn_generation); absent, the otxn models an ordinary non-emitted transaction (otxn_burden() == 1, otxn_generation() == 0).
  • state_entry(key, value) — pre-seeds one of this hook’s own state entries (own account, own namespace). key must be 1..=32 bytes.
  • foreign_state_entry(ns, acc, key, value) — pre-seeds a state entry belonging to another (account, namespace).
  • grant(target_account, ns, authorize) — models a HookGrant on target_account’s ledger object, authorizing a hook matched by authorize: Grant to write into (target_account, ns) via state_foreign_set. Grant::hook_hash(hash) matches by the currently invoked position’s hook hash regardless of account, Grant::account(acc) by hook account regardless of hash, Grant::both(hash, acc) requires both, and Grant::any() is unconditional. Direction matters: a write to this hook’s own account never consults grants at all — grants only gate a write into another account’s namespace, exactly like the real Hook API. Matching is presence-only (no signature verification); anything deeper stays end-to-end territory.
  • ledger_seq(seq) / ledger_time(t) / ledger_last_hash(hash) — the current ledger sequence, the previous ledger’s close time, and the previous ledger’s hash, read back by ledger_seq() / ledger_last_time() / ledger_last_hash(). ledger_last_hash defaults to [0u8; 32] unless called.
  • own_namespace(ns) — overrides this hook’s own default state namespace ([0u8; 32] unless called).
  • max_state_value_len(n) — overrides the cap (bytes, default 256, matching xahaud’s maxHookStateDataSize at state scale 1) on a single state value; a write over the cap fails, exactly like the real host — the technique the worked example above uses to force a deterministic state_set failure.
  • base_fee_drops(drops) — overrides the per-drop base fee the etxn_fee_base/fee_base approximation multiplies by (default 10 drops; see Fees are an explicit approximation).
  • ledger_object(keylet, sto) — seeds a ledger object at its 34-byte keylet, serialized as sto — backs slot_set/ledger_keylet (see Slots and Ledger Objects).
  • otxn_meta(sto) — seeds the current transaction’s metadata — backs meta_slot.
  • xpop(tx, meta) — seeds an XPOP’s (transaction, metadata) byte pair — backs xpop_slot.
  • strict_can_emit(bool) — on by default: after invoke, asserts every transaction type this invocation committed to emitted() is one the invoked entry’s #[hook(.., can_emit = [..])] list declares, matching the real host’s HookCanEmit enforcement. A violation panics — a test-author assertion, not a Hook API error path. An entry with no can_emit declaration is unrestricted (no check), while a declared-empty can_emit = [] rejects every emission — the same three-state distinction the SetHook metadata carries on-chain. Pass strict_can_emit(false) to opt out for a lower-fidelity test that deliberately emits outside the declaration.

Direct-entry invocation: no HookOn filtering

invoke::<C>(index) is a direct entry call: it runs the declared entry at index unconditionally, even if the seeded Otxn’s transaction type would never have triggered that entry on-chain (#[hook(.., on = [..])]’s HookOn filtering is not evaluated at all in Phase 1). This is the one place TestEnv::strict_can_emit matters — the entry’s own declarations are still checked for can_emit, just not for on. Reproducing “does this chain’s HookOn routing actually dispatch to the entry I expect” is end-to-end territory; see Hook Chains for how HookOn is computed from on = [..].

Callback (#[cbak]) invocation

invoke_cbak::<C>(index, outcome) runs the entry’s paired #[cbak(index)] body directly, standing in for xahaud’s own post-application callback dispatch — outcome is CbakOutcome::Success(txn) or CbakOutcome::Failure(txn) for one of env.emitted()’s transactions, mirroring the 0/1 the real wasm cbak(u32) argument carries for a successfully-applied vs. failed emission; a #[cbak] fn that declares an EmitOutcome argument sees EmitOutcome::Applied/EmitOutcome::EmitFailure accordingly. otxn_burden/otxn_generation read straight off the emitted transaction’s own EmitDetails fields in both cases (not incremented, unlike etxn_burden/etxn_generation’s “next emission” derivation). For the duration of the call, the otxn (otxn_field/otxn_type/otxn_id) differs by outcome — exactly what a real callback sees: Success presents the emitted transaction itself; Failure presents the ttEMIT_FAILURE pseudo-transaction the real host applies instead, carrying sfLedgerSequence (the env’s own ledger sequence), sfTransactionHash (the emitted transaction’s hash), and the emitted transaction’s own sfEmitDetails, with its own otxn_id distinct from the emitted transaction’s hash. The swap is undone as soon as the call returns: a later invoke on the same TestEnv sees the originally seeded otxn again. Everything else about the call (fresh InvocationContext, world snapshot, accept!/rollback!/return mapping) is identical to invoke.

let exit = env.invoke::<EmitTxn>(0);
assert!(exit.is_success());
let txn = env.emitted()[0].clone();

let cbak_exit = env.invoke_cbak::<EmitTxn>(0, CbakOutcome::Success(txn));
assert!(cbak_exit.is_success());

etxn_details (and therefore prepare) writes an EmitDetails.EmitCallback field, holding this hook’s own hook_account, exactly when the entry that performs the emit declares a #[cbak] body — matching xahaud’s own hookCtx.result.hasCallback check. invoke_cbak panics if the transaction in outcome has no EmitCallback at all (most commonly: it was emitted by an entry with no #[cbak]), or if EmitCallback’s account or the transaction’s EmitHookHash don’t match this TestEnv’s own hook_account/seeded hash — either way, no genuine on-chain callback could ever be dispatched for that transaction against the entry being invoked. Also panics if the entry at index declares no #[cbak] body at all.

Hook API coverage

As of .claude/design/TESTENV_PHASE2_DESIGN.md’s final stage (P2-E), the mock backend answers the entire extern.h Hook API surface a hook can call, _g (guard enforcement) excepted:

FamilyCovered
Statestate, state_set, state_foreign, state_foreign_set, and every as-int64 variant (state_u64, state_foreign_u64, …)
Originating transactionotxn_field, otxn_type, otxn_id (its flags argument is accepted but ignored — the seeded Otxn::id is always returned as-is), otxn_param, otxn_burden, otxn_generation, otxn_slot
Hook identityhook_param, hook_account, hook_hash(hook_no), hook_pos — hook_param silently truncates into a too-short buffer and reports the truncated length, mirroring xahaud’s own asymmetry with otxn_param (which returns TooSmall)
Control leftovershook_again (once per invocation, ALREADY_SET on repeat), hook_skip (add/delete directives, no chain model), hook_param_set (per-hook-hash overrides, precedence over seeded hook_params — see below)
Ledgerledger_seq, ledger_last_time, ledger_last_hash, ledger_nonce, ledger_keylet
Feesfee_base (constant)
Emissionetxn_reserve, etxn_fee_base, etxn_details, etxn_burden, etxn_generation, etxn_nonce, emit, prepare
Controlaccept, rollback
Tracingtrace, trace_num, trace_float (captures the raw XFL i64 bit pattern as its 8-byte big-endian encoding, the same convention trace_num uses — not a decimal “mantissa*10^exponent” rendering; decode it yourself from TraceLine::data if a test needs the human-readable value)
Float (XFL)the full float_* family (float_set, arithmetic, comparison, float_sto/float_sto_set, float_int, float_log, float_root, …) — see XFL: Decimal Floating Point
Slotthe full slot_* family plus otxn_slot/meta_slot/xpop_slot — see Slots and Ledger Objects
STOsto_subfield, sto_subarray, sto_validate, sto_emplace, sto_erase
Util / Keyletsutil_sha512h, util_accid, util_raddr, util_verify, util_keylet/util_keylet_buf, and all 26 typed keylet_* helpers plus their keylet_*_into out-param twins — see Keylets
Callbacksinvoke_cbak::<C>(index, outcome) runs a declared #[cbak(index)] body directly — see Callback invocation above

hook_param’s override precedence (hook_param_set, P2-E) reduces xahaud’s real chain-forward semantics — any later hook in the same chain execution sees a param override an earlier one set — to this harness’s explicit-invocation model: an override only takes effect on a separate, later invoke/invoke_cbak call seeded with the same hook_pos/ hook_hash as the call that set it (it commits only on that earlier call’s accept!), never within the same invocation that called hook_param_set.

See What this harness does not model below for the honest, shrunk list of what is genuinely out of scope even now that every family above is implemented.

What this harness does not model

Documented, not accidental — each of these stays end-to-end (or rshooks build check) territory, honestly enumerated from the implementation as of .claude/design/TESTENV_PHASE2_DESIGN.md’s final stage (P2-E):

  • Guard enforcement (_g) and HookOn chain routing. _g keeps returning 0 natively, with or without testenv — guard correctness is rshooks build check’s job (see The rshooks CLI) and the end-to-end suite’s, not this harness’s. Chain execution and on = [..] trigger filtering are not evaluated at all — covered above.
  • rshooks::raw (direct rshooks_core::* calls) bypasses the mock entirely, for every family — not just the ones this book’s worked examples happen to use. The backend only intercepts the specific call sites listed in crates/rshooks/testenv-call-sites.txt (the rshooks wrapper layer’s api/*.rs functions, plus xfl.rs/xfl_unchecked.rs’s own raw float_* operator call sites — both were bridged as of P2-E; a hook or helper crate reaching rshooks_core directly anywhere else keeps hitting the real NOT_IMPLEMENTED host stubs under testenv, exactly as on any other native build). This is deliberate — rshooks_core is documented elsewhere in this book as the project’s own WCE escape hatch, and that hatch stays real on native builds too, rather than silently gaining mock coverage the wasm build doesn’t have.
  • Statics outside HookStatic are not reset between invocations. An ordinary hand-declared static keeps its value across every invoke call in the same process, unlike the fresh-wasm-instance-per-invocation reality on-chain. HookStatic (see Emitting Transactions’s statics idiom) is the one pattern this harness does reset correctly: on native under testenv, take() hands out a freshly leaked clone of the static’s pristine value once per invocation (not once per process), so a HookStatic-held template like examples/10_emit-txn’s TXN behaves the same on the second invoke call as it did on the first. This is why HookStatic’s payload type must implement Clone — an unconditional requirement of the type, identical on every target, not something that only shows up under testenv.
  • Fee/reserve economics are explicit approximations. etxn_fee_base’s default responder is base_fee_drops × etxn_burden, not a parse of the actual transaction blob through xahaud’s real ledger fee calculator, and there is no reserve/owner-count model at all. Override base_fee_drops if a test needs a specific number, but don’t treat the result as the real on-chain fee.
  • slot_set/ledger_keylet only resolve 34-byte keylets. xahaud also accepts a bare 32-byte transaction hash for some slot operations; this harness treats that shape as DOESNT_EXIST rather than modeling a separate transaction-hash lookup table.
  • Amendment gates are assumed active. Every Hook API function behaves as if every amendment it depends on is already enabled — there is no per-amendment feature-flag model.
  • ExitType::Return is provisional — covered above.
  • Real reserve/consensus economics and instruction metering are unmodeled entirely.

Where to go next

  • Hook Chains covers HookOn/can_emit and how a #[state(...)] field declared once is shared across every entry in a chain — background for TestEnv::strict_can_emit and the hook_hash/ hook_pos builders above.
  • Accept, Rollback, and Errors covers accept!/rollback!/hook_errors! themselves — this page only covers how their outcomes surface through TestEnv::invoke.
  • Emitting Transactions covers txn_template! and HookStatic in full — background for the emission-capture assertions and the HookStatic reset behavior above.
  • docs/E2E-TESTING.md (repository root) covers the live-node suite this page’s harness deliberately does not replace.

Macro Reference

A lookup table of every user-facing macro, derive, and attribute rshooks exports. Each entry is a one-line purpose plus a minimal invocation sketch — for the full grammar, worked examples, and edge cases, follow the link into the tutorial chapter that covers it, or the macro’s own rustdoc in crates/rshooks/src/lib.rs.

Chain declaration and entry points

attributepurposesketch
#[hooks(description = "...")] on a structDeclares this crate’s Hook chain — a container for shared #[state]/#[hook_param]/#[otxn_param] fields, no runtime instance. Exactly one per crate.#[hooks] pub struct MyHook;
#[hooks] on an inherent implDeclares this chain’s entry points. Exactly one per #[hooks] struct, in the same module.#[hooks] impl MyHook { .. }
#[hook(<index>, ...)]Inside a #[hooks] impl: declares one Hook entry at the given chain position (0..=9, required). The fn must return a type implementing the sealed EntryReturn trait — currently, only HookResult (paired with ? — see Accept, Rollback, and Errors), with accept!/rollback! still usable in the body as an escape hatch (they diverge, coercing to HookResult). Any other return type is a compile error naming EntryReturn. Extra ident: Type arguments after &self declare Hook Parameter Signature Interface parameters, decoded before the body runs — requires the unstable-param-sig-interface feature — see Hook and Transaction Parameters. Named args: name, on/on_incoming+on_outgoing, can_emit, description.#[hook(0, name = "accept", on = [Invoke])] fn main(&self) -> HookResult { accept!() } / #[hook(0, on = [Invoke])] fn increment(&self, account: AccountId, count: u16) -> HookResult { .. }
#[cbak(<index>)]Pairs with a #[hook] at the same index; exports cbak for that index — the optional callback invoked when a transaction this entry emitted later settles. Index only — a #[cbak] fn takes &self plus at most one further argument, the callback outcome (EmitOutcome or a raw u32, populated from the host’s cbak(u32) argument); it cannot declare signature-parameter arguments (a compile error), since those apply to #[hook] only. Same EntryReturn-bound return type as #[hook].#[cbak(0)] fn my_cbak(&self, outcome: EmitOutcome) -> HookResult { accept!() }

An entry’s return-type error (a return type that doesn’t implement EntryReturn) is reported twice per bad entry: once on that entry fn’s own -> Ty (or the fn name when the return type is omitted), and once on the #[hooks] attribute from the generated wrapper body. In a chain with several entries, each bad entry still gets its own pair.

Receivers on #[hook]/#[cbak] entries and impl helpers

A #[cbak] entry function requires &self, optionally followed by one argument — the callback outcome (EmitOutcome or a raw u32). A #[hook] entry function requires &self, optionally followed by signature-parameter arguments (ident: Type, decoded before the body runs, requires the unstable-param-sig-interface feature — see Hook and Transaction Parameters). Either way, the entry always receives the chain declaration by shared reference, even for a unit-struct or field-less chain with nothing to read through it. A non-attributed helper declared inside the same #[hooks] impl accepts either no receiver or &self.

receiverentry (#[hook]/#[cbak])impl helper
none (fn helper() -> ...)no — diagnostic: “hook entry functions take &self — the chain declaration is passed by shared reference (it is zero-sized)”yes
&self (fn main(&self) -> HookResult)yes — receives the chain’s single zero-sized static by shared reference; reach its fields as self.<field>yes
self / mut self / &'a self / self: Tno — diagnostic: “use &self — hook entrypoints receive the chain declaration by shared reference (it is zero-sized)”no — same diagnostic
&mut self / &'a mut selfno — diagnostic: “chain handles are zero-sized and immutable; ledger state is accessed through the handles, not by mutating the struct — use &self”no — same diagnostic

Code outside the annotated impl (a free function, another module) has no self to borrow and reaches the same static by the struct’s own name instead (MyHook.some_field) — see Anatomy of a Hook.

See Anatomy of a Hook, Hook Chains, Per-Hook Attributes, and Emitting Transactions.

Control flow & exit

macropurposesketch
accept!Terminate successfully, optionally with a message and code.accept!() / accept!(b"ok", 0)
rollback!Terminate with failure, rolling back state changes.rollback!(b"blocked", FirewallError::BlockedAccount)
guard!Bound a loop’s iteration count for the host’s static guard check.loop { guard!(10); .. }
guard_m!Like guard!, for multiple loops sharing one source line ($n disambiguates).guard_m!(10, 0);
hook_errors!Declare a #[repr(i64)] error enum usable directly as a rollback!/accept! code, and ?-convertible into rshooks::exit::Rollback; an optional per-variant => b"msg" clause supplies that conversion’s message.hook_errors! { pub enum E { BlockedAccount = 1 => b"blocked" } }
exit_on_err!Unwrap a Result<T, E: Into<i64>>, rolling back on Err.let v = exit_on_err!(b"failed", check());
rshooks::exit::{Accept, Rollback, HookResult}Typed entry-return types (not macros): HookResult is Result<Accept, Rollback>; Accept::new/Rollback::new take a message and code (Accept::from_code(code) / Rollback::from_code(code) for an empty message).Ok(Accept::new(b"ok", 0)) / Err(Rollback::new(b"no", 1))

See Accept, Rollback, and Errors and Guards and Loops.

Data & typing

macro/attributepurposesketch
#[derive(HookData)]Encode/decode a fixed-size, named-field struct as a hook-state value (or a parameter value, or a nested field).#[derive(HookData)] struct Deposit { amount: u64 }
#[derive(HookKey)]Encode a fixed-size, named-field struct as a hook-state key (encode-only, 32-byte bound checked at derive time).#[derive(HookKey)] struct DepositKey { tag: u8, owner: AccountId }
#[derive(ParamName)]Encode a fixed-size, named-field struct as a Hook API parameter name (encode-only, 1–32-byte bound checked at derive time).#[derive(ParamName)] struct SeatParamName { topic: u8, seat: u8 }
#[derive(ParamValue)]Decode a fixed-size, named-field struct as a Hook API parameter value (decode-only).#[derive(ParamValue)] struct Config { min_amount: u64 }
#[state(key = ...)] / #[state(key_by = ...)]On a #[hooks] struct field of type State<V>: declares a hook-state entity — key + value pairing, with .get()/.set()/.update()/.delete() (and .at(args) for key_by).#[state(key_by = DepositKey)] deposits: State<Deposit>,
state_keys!Declare an enum of hook-state keys, each variant its own real byte length.state_keys! { enum DataKey { Counter, Balance(AccountId) } }
#[hook_param(name = ...)] / #[hook_param(name_by = ...)]On a #[hooks] struct field of type HookParam<V>: declares a Hook parameter (this hook’s own installed parameters) — name + value pairing, .get()/.get_or_default()/.get_required() (and .at(args) for name_by).#[hook_param(name = b"CFG", default = Config::default())] config: HookParam<Config>,
#[otxn_param(name = ...)] / #[otxn_param(name_by = ...)]Identical grammar to #[hook_param], but reads the originating transaction’s parameters.#[otxn_param(name = b"INS", required)] instruction: OtxnParam<Instruction>,
#[state_interface(id = .., key(..), value(..))](unstable-state-interface) On a #[hooks] struct field of type State<VName>: declares a Hook State Interface entity — the macro generates struct VName from value(..) and a typed key encoder from key(..), sharing #[state]’s .get()/.set()/.update()/.delete()/.at(args) accessors.#[state_interface(id = 0, key(account: AccountId), value(amount: u64))] balances: State<Balance>,

See Hook State, Hook and Transaction Parameters, and Typed Data with Derives.

Compile-time literals

macropurposesketch
XFL!Encode a decimal numeric literal into a bit-exact xfl::XFL value at compile time (never via f64).const RATE: XFL = XFL!(0.003333333333333333);
account_id!Decode a classic r-address into an AccountId at compile time.const OWNER: AccountId = account_id!("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh");

See XFL: Decimal Floating Point and Keylets (which covers account_id!).

Transactions

macropurposesketch
txn_template!Declare a typed, byte-exact emitted-transaction template: field list in, new()/setters/prepare_for_emit()/emit() out.txn_template! { struct Payment { transaction_type = ttPAYMENT, .. } }

See Emitting Transactions.

Slots

macropurposesketch
slot_path!Walk a multi-hop SlotObject path in one auto-assigned slot, rewritten in place per hop — no ?-chain slot leaks.slot_path!(root[sfSigners][0][sfAccount])

See Slots and Ledger Objects.

Tracing & buffers

macropurposesketch
trace!Emit a debug trace message (and optional byte payload). Compiles to nothing unless the trace feature is enabled.trace!(b"checkpoint");
trace_num!Emit a trace message followed by an integer.trace_num!(b"count", count);
trace_float!Emit a trace message followed by an XFL value.trace_float!(b"rate", rate);
pad!Zero-pad a constant byte string to a fixed-size array at compile time, src at the front.const KEY: StateKey = StateKey(pad!(b"counter"));
pad_left!Same as pad!, but src at the end (zero bytes first).const KEY: StateKey = StateKey(pad_left!(b"counter"));

See Tracing and Debugging and Hook State.

Build metadata

There’s no separate metadata macro — a chain’s descriptive and SetHook-facing metadata is the #[hooks(description = ...)] struct attribute plus each entry’s #[hook(<index>, name = ..., on = ..., can_emit = ..., description = ...)] arguments, listed under “Chain declaration and entry points” above. rshooks build extracts them into a per-entry sidecar JSON and a SetHook template — see Per-Hook Attributes.

The Prelude

use rshooks::prelude::*; is the standard way to bring rshooks’s ergonomic surface into scope. Nearly every code sample in this book starts with it (usually paired with use rshooks::*; for the macros, which live at the crate root rather than in prelude). This page lists exactly what it re-exports, grouped by area, and the two things it deliberately leaves out.

The prelude is deliberately not a full glob of rshooks-core — the raw crate’s api::* functions share names with rshooks’s own wrappers (both define state, for instance), so pulling in the whole thing would create ambiguity. Only the constant-only modules come through from the raw layer; everything else is rshooks’s own typed surface.

API wrapper modules

Every function from these rshooks::api submodules, except the numbered slot functions (see “Deliberate absences” below):

modulecovers
api::controlaccept/rollback (the functions accept!/rollback! call into).
api::etxnTransaction emission: etxn_reserve, etxn_fee_base, etxn_details, emit.
api::floatXFL arithmetic host calls (float_multiply, float_divide, mulratio, …).
api::hook_ctxThis hook’s own context: hook_account, hook_param, hook_param_typed, hook_param_exact, hook_param_set, and related.
api::keyletThe 26 typed keylet_xxx helpers (one per KEYLET_* constant) plus their 26 keylet_xxx_into out-param twins.
api::ledgerLedger-wide queries: ledger_seq, ledger_last_hash, ledger_last_time, and related.
api::otxn (partial)otxn_field, otxn_field_exact, otxn_field_typed, otxn_field_u64, otxn_param, otxn_param_exact, otxn_param_typed, otxn_type, otxn_id, otxn_id_buf, otxn_burden, otxn_generation, OtxnFieldValue — listed by name rather than globbed, so a future addition upstream is a deliberate act. otxn_slot is excluded (a slot function).
api::stateTyped single-value state helpers (state_u32, state_xfl, state_update_u64, …) alongside the composite layer below.
api::stoSTObject parsing helpers.
api::traceThe support functions backing trace!/trace_num!/trace_float!.
api::utilutil_keylet, util_accid, util_raddr, util_verify, util_sha512h, and related.

See Reading the Originating Transaction, Hook State, Hook and Transaction Parameters, Emitting Transactions, and Keylets.

The typed slot layer

slot_obj::{AmountBytes, CastTarget, IssueData, SlotKey, SlotObject} — the handle-based wrapper around the Hook API’s 255 numbered slot registers, addressed by type instead of by raw integer. See Slots and Ledger Objects.

sfield constants

crate::sfield::* — the typed SField<T> constants (sfAccount, sfSequence, …), one per serialized field, each carrying its value type as a generic parameter. These are what SlotObject::get and the typed otxn/state accessors take. See Slots and Ledger Objects.

buf_eq helpers

crate::buf_eq::* — buf_eq_8/_20/_32/_33/_34/_40/_48/_64: fixed-size buffer equality as straight-line word-compare code, avoiding the compiler-generated bcmp-style loop a plain == on a [u8; N] can lower to at opt-level = "z". The same module also has buf_cmp_20, a loop-free 160-bit big-endian ordering for two 20-byte buffers (e.g. two AccountIds) — XRPL/Xahau’s “high”/“low” account ordering, used to canonicalize a pair of accounts (picking the low/high side of a RippleState trustline keylet). See Guards and Loops.

no_unroll

crate::no_unroll (re-exported at the crate root too) — routes a loop’s induction variable through core::hint::black_box at its comparison, so LLVM can no longer prove the trip count at compile time and keeps the loop as one real loop construct instead of fully unrolling it. Only worth reaching for when a small, provably-bounded outer loop wrapping a guard!-protected inner loop would otherwise get fully unrolled at opt-level = 3 — unrolling physically duplicates the inner loop, and the guard checker (which walks compiled bytecode) then counts its worst-case cost once per duplicate instead of once total. while no_unroll(i) < N { .. } in place of while i < N { .. }.

Convert traits

crate::convert::{FixedRead, FromBytes, ToBytes, TypedParamName} — the traits #[derive(HookData)]/#[derive(HookKey)]/#[derive(ParamName)]/ #[derive(ParamValue)] implement, and the trait a #[hook_param(...)]/ #[otxn_param(...)] field’s name carries. See Typed Data with Derives.

The decl module: struct field handle types

crate::decl::{State, HookParam, OtxnParam} — re-exported at the crate root and here in the prelude, since these are the field types a #[hooks] struct’s #[state]/#[hook_param]/#[otxn_param] fields are written with (see Hook State and Hook and Transaction Parameters). The rest of decl — StateEntry, HookParamAt, OtxnParamAt (the handles .at(args) returns for a keyed/named-family field) and the *Spec/HookChainEntries traits — is the macro-generated side of the handshake, not re-exported here or at the crate root; reach it at rshooks::decl::StateEntry etc. when a field’s own .at(args) return type needs naming (e.g. in a helper function’s signature).

HookError/Result

crate::error::{HookError, Result} — the Result<T, HookError> alias every typed Hook API wrapper returns, including HookError::NotImplemented (what a raw call returns on a host build). See Accept, Rollback, and Errors.

Accept/Rollback/HookResult

crate::exit::{Accept, Rollback, HookResult} — the typed entry-return types: HookResult is Result<Accept, Rollback>, the only return type a #[hook]/#[cbak] entry may declare (-> i64 does not compile). The sealed EntryReturn conversion trait those types compile through is not in the prelude (or nameable at all outside its fully qualified path) — a hook author never calls it directly. See “Typed entry returns: HookResult”.

State functions

crate::state::{StateKeyEncode, TypedStateKey, state_delete, state_foreign_get, state_foreign_get_typed, state_foreign_set_loose, state_foreign_set_typed, state_foreign_update_loose, state_foreign_update_typed, state_get, state_get_typed, state_set_loose, state_set_typed, state_update_loose, state_update_typed} — the composite/typed hook-state layer a #[state(...)] struct field’s accessors forward to, plus the _foreign twins for reading another account’s state. See Hook State.

HookStatic

crate::static_cell::HookStatic — the safe, const-constructible, take-once cell for templates and large buffers that should land in a wasm data segment/BSS rather than be materialized by runtime stores. See Anatomy of a Hook.

Typed read views

crate::views::ledger::LedgerEntryCommonFields and crate::views::tx::{TransactionCommonFields, TransactionCommonSlotFields} — the common-field traits every generated view (views::tx::Payment, views::ledger::AccountRoot, …) implements, covering the fields every transaction (sfAccount, sfFee, sfMemos, …) or every ledger entry (sfLedgerEntryType, sfFlags, …) carries. Importing the prelude is enough to call these common accessors on any view; the view types themselves (one per transaction/ledger-entry format) live under rshooks::views::{tx, ledger} and are not globbed into the prelude — use rshooks::views::tx::Payment; explicitly. See rshooks::views’s own module doc comment for the full model (originating-transaction vs. slot-backed sources, soeREQUIRED/soeOPTIONAL field typing, the active-amendments/all-amendments format tiers).

StoWriter

crate::sto_writer::StoWriter — the bounded, allocation-free writer for a runtime-sized STObject/STArray (a transaction whose shape txn_template! can’t describe at compile time, e.g. Remit’s sfAmounts). See Emitting Transactions and The StoWriter API.

LedgerEntryType

crate::ledger_entry_type::LedgerEntryType — the typed ledger-entry-type enum (LedgerEntryType::AccountRoot, …), decoded from a ledger object’s sfLedgerEntryType the same way TxType decodes a transaction’s type.

Hook Parameter Signature Interface (sig), behind unstable-param-sig-interface

crate::sig::{Blob, IssueBytes, SigName, SigParamType, hook_sig_param, otxn_sig_param, otxn_sig_param_opt} — re-exported in the prelude only when the unstable unstable-param-sig-interface cargo feature is enabled on rshooks; the sig module itself doesn’t exist in the crate at all otherwise. This is the support layer behind #[hook(..)]’s signature- parameter fn arguments — see Hook and Transaction Parameters. (The module also has a hook_sig_param_opt function, the hook_param twin of otxn_sig_param_opt; it is not re-exported in the prelude, so reach it at rshooks::sig::hook_sig_param_opt if needed.)

TxType

crate::tx_type::TxType — the typed transaction-type enum (TxType::Payment, …), used by otxn_type and by #[hook(<index>, ...)]’s on/can_emit lists. See Reading the Originating Transaction.

Types

crate::types::* — protocol value newtypes: AccountId, Hash, Keylet, StateKey, NameSpace, Nonce, PublicKey, CurrencyCode, IssuedAsset (a CurrencyCode + issuing AccountId pair), the STObject/ STArray/Amount/Issue/Opaque marker types SField<T> and StoWriter’s field writers take, and the length constants (ACC_ID_LEN, STATE_KEY_LEN, EMIT_DETAILS_MAX_LEN, …).

XFL / XFLUnchecked

crate::xfl::XFL and crate::xfl_unchecked::XFLUnchecked — the checked and hot-path-unchecked decimal floating-point types. See XFL: Decimal Floating Point.

The XFL! macro

rshooks_macros::XFL — re-exported here too, alongside the xfl::XFL type of the same name. This is not a naming collision: a macro and a type live in separate Rust namespaces, the same relationship std::Clone (trait) and #[derive(Clone)] (macro) have.

Constant families

rshooks_core::{consts::*, lets::*, ls_flags::*, tts::*, tx_flags::*} — the C-verbatim constant tables: KEYLET_*/COMPARE_* (consts), ltXxx ledger-entry-type codes, lsfXxx ledger-entry flags, ttXxx transaction-type codes, and tfXxx/asfXxx transaction/account flags. See The Raw Layer for the full family list, including the ones not re-exported here (sfcodes, error, backend).

Two deliberate absences

  • The raw sfcodes glob. sfield’s typed SField<T> constants take those same names, so sfSequence in the prelude is an SField<u32>, not a bare u32. The raw table is still available at rshooks::raw::sfcodes::* for const contexts where Into cannot be called — txn_template!’s field tables, or a const header expression. SField::code() is the other bridge between the two.
  • The numbered slot functions. slot_set/slot_clear/slot_subfield/ otxn_slot/… address the same 255 registers SlotObject manages, and mixing the two silently corrupts handles. They stay public at rshooks::api::slot::* (plus rshooks::api::otxn::otxn_slot) — reaching for them explicitly at least makes the escape hatch visible at the call site.

Workarounds: rshooks::raw::sfcodes::* for raw sfield codes, rshooks::api::slot::* for the numbered slot API. See The Raw Layer and Slots and Ledger Objects.

The Raw Layer

rshooks::raw is a direct re-export of the rshooks-core crate — a plain alias, not a re-exporting wrapper module, so every path under rshooks::raw::* is identical to the same path under rshooks_core::*. This is the lowest layer of the toolchain: no_std, zero-logic, 1:1 translations of the xahaud hook/ C headers into Rust, with no Result type, no typed wrappers, and no ergonomics of any kind.

Everything else in rshooks — api, state, slot_obj, the macros — is built on top of this layer. Most hook code never needs to reach into it directly; see The Prelude for what the ergonomic surface covers instead.

What’s in it

modulecontents
raw::api (via raw::*)The 75 raw Hook API function declarations (_g plus 74 functions), unsafe extern "C", imported from wasm import module env — one line per extern.h function, parameter names and types kept verbatim (read_ptr: u32, i64 returns) so C hook source and this file can be compared line by line.
raw::hostHookHost: the same 75 functions again, as trait methods with identical names/signatures — an indirection point for a future native test host. Not used by rshooks or the examples today; the flat free-function API above remains the public surface they call.
raw::sfcodesEvery sfXxx serialized-field code (325 fields), each a u32 packing (type << 16) + index, mirrored verbatim from sfcodes.h.
raw::ttsEvery ttXxx transaction-type code (ttPAYMENT, ttHOOK_SET, …), from tts.h.
raw::letsEvery ltXxx ledger-entry-type code (ltACCOUNT_ROOT, ltHOOK, …) — what rshooks::ledger_entry_type::LedgerEntryType decodes and sfLedgerEntryType carries — from the vendored ledger_entries.macro.
raw::constsKEYLET_* and COMPARE_* constants from hookapi.h, plus tfCANONICAL, the atACCOUNT family, and the amAMOUNT family from macro.h.
raw::ls_flagsEvery lsfXxx ledger-entry flag from ls_flags.h, flattened from the header’s per-ledger-entry-type C enums into one list (no name collides across enums).
raw::tx_flagsEvery tfXxx transaction flag and asfXxx account flag from tx_flags.h, flattened the same way. A few MPTokenIssuanceCreateFlags members alias ls_flags values in the C header and are kept as references to the ls_flags const rather than re-typed literals, so the two stay in sync by construction.
raw::errorEvery Hook API error code (SUCCESS = 0, OUT_OF_BOUNDS = -1, …, NOT_IMPLEMENTED = -14, INVALID_FLOAT = -10024), kept verbatim from error.h.
raw::backend#[doc(hidden)], native-only: the HostBackend trait rshooks-testenv’s mock host implements, plus install() to swap one in for the duration of a scope. An unstable internal contract between rshooks/rshooks-core/rshooks-testenv, not a stable public API — but reachable, and occasionally useful directly in a test that needs to stub one specific host call (float_sto, say) without pulling in the whole TestEnv model; see rshooks::sto_writer’s own testenv_tests module or examples/17_sto-writer’s in-crate tests for the pattern.

Every constant module is @generated from the vendored xahaud headers under crates/rshooks-core/vendor/xahaud-hook/ — not hand-maintained — so a name or value here always matches the upstream C header it was generated from.

When a hook author actually needs it

Two situations pull a hook out of the typed rshooks::api/prelude surface and down into raw:

  • Const contexts needing a raw u32 sfield code. The typed sfield constants (crate::sfield::sfSequence, etc., what the prelude re-exports under the plain sfXxx names) are SField<T> values, not bare u32s — fine for ordinary calls, but unusable where a raw integer is required at compile time. txn_template!’s field tables are the main example: sfcode values there come from rshooks::raw::sfcodes::* (or SField::code()) so they can participate in const fn offset arithmetic. See Emitting Transactions and Macro Reference.
  • An API the wrapper doesn’t cover. rshooks::api wraps the common Hook API surface with Result-returning, panic-free functions, but if a specific raw host call has no typed wrapper yet, rshooks::raw’s unsafe extern "C" declarations are still there to call directly.

Host builds: every raw call is a stub

On a non-wasm32 target (an ordinary cargo check/cargo test, what rust-analyzer runs for completion and diagnostics), the wasm import block doesn’t exist — there is no host to link against. rshooks-core instead provides the same signatures as deterministic stub functions that return NOT_IMPLEMENTED (raw::error::NOT_IMPLEMENTED, -14); the _g stub is the one exception, returning 0 (“guard check passed”), so guarded loops still run under host tests. None of the stubs panic.

This is what makes cargo check/cargo test work at all for a no_std hook crate outside the wasm host, and it’s also why every doctest and unit test in this book that calls a Hook API function asserts Err(HookError::NotImplemented) rather than a real value — the typed wrappers in rshooks::api surface the raw stub’s NOT_IMPLEMENTED as HookError::NotImplemented, so the same assertion works whether the call went through rshooks::api or rshooks::raw directly.

unsafe, and bypassing the wrapper

Every function in rshooks::raw::api (and every HookHost method) is unsafe extern "C" — these are bare FFI declarations with no argument validation, no length checking, and no Result conversion. Calling into raw directly means taking on everything rshooks::api’s typed wrappers normally handle: buffer sizing, error-code-to-HookError translation, and (for the slot API in particular) handle bookkeeping that slot_obj::SlotObject otherwise manages for you. Prefer the typed surface in The Prelude unless a hook has a specific, verified reason to drop down here.

Examples Index

examples/ is a runnable catalog of Hooks written with rshooks and built with rshooks-build, numbered in suggested reading order; every code sample in this book is adapted from one of these. See the examples README on GitHub for the full index (with each example’s matching book chapter), build/test commands, and the source-style rules every example follows.