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 four 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 (#[hook]/#[cbak], metadata!, XFL literals, and more).
rshooksno_std, ergonomic wrapper over rshooks-coreResult-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 a SetHook-valid WASM binary: cargo build for wasm32v1-none, then a hook-cleaner and guard-checker, natively in Rust.

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: entry points, accept/rollback and the error model, the loop-guard system, and tracing.
  • 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 metadata! declaration it consumes.
  • 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.0.1"
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.0.1"

[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.0.1", 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.

The source

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

#![no_std]

use rshooks::*;

metadata! {
    name: "accept-all",
    description: "Accepts every transaction selected by HookOn.",
    HookOn: [Invoke],
    HookName: "accept",
}

#[hook]
fn my_hook() -> i64 {
    trace!(b"accept-all: accepting transaction");
    accept!()
}

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

[dependencies]
rshooks = { version = "0.0.1", 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 #[hook]/#[cbak] attribute macros, the metadata!/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.

metadata!

The metadata! block declares build-only information about the Hook: a display name, an optional description, which transaction types trigger it (HookOn), and (optionally) its on-ledger HookName. It’s not required — a Hook without it still builds and runs — but rshooks uses it to generate a JSON sidecar describing the binary. See Hook Metadata for the full grammar.

#[hook]

#[hook] turns a plain, argument-less fn my_hook() -> i64 into the wasm export the Hook host requires. It expands to:

#[unsafe(no_mangle)]
pub extern "C" fn hook(_reserved: u32) -> i64 {
    my_hook()
}

The annotated function must take no arguments and return i64, with no async/unsafe/const/extern modifiers and no generics — #[hook] rejects anything else at compile time with a pointed error rather than producing a malformed export. The function’s own name (my_hook here) is just a convention; what matters is the hook export it produces. Use #[cbak] the same way to export the optional settlement callback, cbak, invoked when a transaction this Hook previously emitted settles.

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

or from elsewhere, pointing at its manifest:

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

This runs cargo build --release --target wasm32v1-none, then post-processes the resulting .wasm — see Building a Hook for exactly what that post-processing does. A successful build prints something like:

worst-case instructions: hook=15 cbak=0
max nesting depth: 0
wrote out/my_hook.wasm
size: 174 bytes
estimated SetHook fee: 870000 drops (0.870000 XAH)
wrote out/my_hook.json

What lands in out/

Two files appear next to your crate’s Cargo.toml:

  • out/my_hook.wasm — the cleaned, SetHook-valid binary: 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.
  • out/my_hook.json — the metadata sidecar, generated because this crate declared metadata!. For accept-all specifically, it looks like:
{
  "name": "accept-all",
  "description": "Accepts every transaction selected by HookOn.",
  "HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
  "HookCanEmit": null,
  "HookName": "616363657074",
  "HookHash": "DCE6A3F81224AE89C557F04D73420D808D9009BCF1CFC1474396CD2DA2D4DF16",
  "WCE": {
    "hook": 15,
    "cbak": 0
  },
  "human": {
    "HookOn": [
      "Invoke"
    ],
    "HookCanEmit": null,
    "HookName": "accept"
  }
}

The WCE (worst-case execution) numbers are the static, guard-derived upper bound on instructions the host will ever execute for 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. Both are computed from the final, cleaned .wasm bytes, so they only exist once a build has run.

From here, Building a Hook explains what each pipeline stage actually does, 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.

The pipeline

rshooks build runs cargo, then a fixed sequence of post-processing and validation steps on the resulting .wasm:

  1. cargo build --release --target wasm32v1-none — compiles your crate exactly as any other Rust crate would be, using your Cargo.toml and its [profile.release] settings (see Installation for why that profile matters). The output is an ordinary cdylib artifact; on its own it is not SetHook-valid — it still exports memory, and Rust’s own code generation gives no guarantee about loop guards or WASM feature usage.
  2. Hook-cleaner — strips the disallowed memory export and any other dead or non-hook/cbak exports, and (for Guard-type, API version 0, modules) flattens and inlines the crate’s call graph into the hook/ cbak entry points, untangling the resulting block/loop/if nesting so it fits the host’s structural limits. This is also the stage that strips a metadata! declaration’s carrier export — see Hook Metadata for why that carrier never reaches the deployable binary.
  3. Guard checker — for API version 0, 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. This step is skipped for API version 1 (Gas-type hooks meter instructions at runtime instead of requiring static guards).
  4. Validator — checks the complete SetHook rule set: exactly one hook export (and at most one cbak), 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).
  5. Metadata sidecar — if the crate declared metadata!, writes the <crate>.json sidecar next to the cleaned wasm, combining the source declaration with the final binary’s HookHash and WCE.

Every one of these steps runs against the exact bytes that will be deployed — the printed WCE and HookHash describe the file actually written to out/, not an intermediate artifact.

Reading the printed report

A successful build prints, in order:

worst-case instructions: hook=15 cbak=0
max nesting depth: 0
wrote out/my_hook.wasm
size: 174 bytes
estimated SetHook fee: 870000 drops (0.870000 XAH)
wrote out/my_hook.json
  • worst-case instructions is the guard checker’s static upper bound on instructions the host will ever execute for each entry point. It only appears for API version 0 (Guard-type) modules — a Gas-type module has no static bound of this kind.
  • max nesting depth is the deepest block/loop/if nesting in the final module, checked against the host’s structural limit.
  • size / estimated SetHook fee are computed directly from the final binary’s 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.

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 --auto-guard

Guards are your responsibility by default: 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. The --auto-guard flag exists mainly for 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). It’s covered in full, including why it’s a footgun if used carelessly and the source-level idioms that usually avoid needing it in the first place, 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 #[hook]/#[cbak] entry points, 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.

The crate shape

Every hook crate is a no_std cdylib:

#![no_std]

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

metadata! {
    name: "accept-all",
    description: "Accepts every transaction selected by HookOn.",
    HookOn: [Invoke],
    HookName: "accept",
}

#[hook]
fn my_hook() -> i64 {
    trace!(b"accept-all: accepting transaction");
    accept!()
}

(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.
  • metadata! declares descriptive and SetHook-facing metadata (name, HookOn, HookCanEmit, and so on) — covered in Hook Metadata. It compiles to a dead wasm export that the build tool reads and then strips; it adds nothing to the final binary.
  • The actual logic lives in a plain function annotated with #[hook].

#[hook] and #[cbak]

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. #[hook] avoids that: it takes a plain, safe function and generates the export for you.

use rshooks::hook;

#[hook]
fn my_hook() -> i64 {
    0
}

expands to the original function unchanged, plus:

#[unsafe(no_mangle)]
pub extern "C" fn hook(_reserved: u32) -> i64 {
    my_hook()
}

The macro enforces the annotated item’s shape exactly, and reports any violation as a compile_error! at the offending token rather than a panic:

  • no arguments, and a return type of exactly -> i64;
  • no async, unsafe, const, or extern modifiers;
  • no generics, no where clause.

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

#[cbak] is the same macro, generating a cbak export instead of hook. A Hook module can optionally export cbak: 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. See Emitting Transactions for a worked #[cbak] example. Both attributes take no arguments of their own — #[hook], not #[hook(...)].

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::fmttrace!, 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

Accept, Rollback, and Errors

A Hook always terminates by calling into the host: either accept, which keeps the originating transaction’s effects, or rollback, which discards them. This page covers rshooks’s Result/HookError type for Hook API failures, the accept!/rollback! macros that actually end execution, 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 45 documented error codes from the Hook API. rshooks decodes that negative range into a typed enum, rshooks::error::HookError:

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::SomeVariant) you can match on, rather than a raw negative integer. HookError::Unknown(i64) exists for forward compatibility, carrying the raw code for any negative value this version of the crate doesn’t yet recognize by name.

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.

Ending execution: accept! and rollback!

A hook must always end by calling one of these two macros. Both 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 hooks written against these macros don’t need a placeholder return value on the branches that call them: rollback! has return type ! (the never type), so it type-checks against whatever the surrounding match/if arm needs to produce.

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).

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 moment one fails:

#[hook]
fn my_hook() -> i64 {
    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();
    }

    accept!()
}

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.

How the codes surface on-ledger

CodeReasonMessage
0(via accept!())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.

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 compiler-generated-loop pitfall that catches most people off guard the first time, and the two source-level idioms rshooks hooks use to avoid it entirely.

Why every loop needs a guard

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

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

guard! and guard_m!

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

use rshooks::guard;

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

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

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

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

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

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

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

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

The compiler-generated-loop pitfall

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

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

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

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

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

The two idioms that avoid it

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

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

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

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

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

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

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

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

Where to go next

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

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 metadata!’s declared HookOn list already, 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:

#[hook]
fn my_hook() -> i64 {
    let Ok(sender) = otxn_field_typed(sfAccount) else {
        rollback!(
            b"firewall: could not read otxn sender",
            FirewallError::CouldNotReadSender
        )
    };

    let Ok(blocked) = BlockedParam.get_value() else {
        accept!()
    };

    // Avoid `==`, which can compile to an unguarded loop.
    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: sender == blocked 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. blocked itself comes from a Hook parameter — see Hook and Transaction Parameters for hook_parameter!.

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 one-line declaration that generates 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. 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.

Tier 3: hook_state! — a key permanently paired with its value type

hook_state! declares a hook-state entity: a key bound to exactly one value type, with four generated accessor methods (get_state/set_state/update_state/delete_state) and a TypedStateKey implementation that also makes it usable with every free state_get_typed/state_set_typed/state_update_typed function. There is no second, independently-chosen value type left for a mismatch to hide in — passing the wrong value for a key is a compile error.

It offers a grammar staircase of six forms, from a fully-fixed key down to a fully composite, runtime-constructed one. Pick the narrowest one that fits:

formkey shapeexample
1fully fixed (a new zero-sized type)hook_state!(RewardRate, RewardRateKey = b"RR" => XFL);
2struct, with a fixed instancehook_state!(Counter, CounterKey {name: [u8; 7]} = {name: *b"counter"} => u64);
3struct, constructed per call sitehook_state!(DepositState, DepositKey {tag: u8, owner: AccountId} => Deposit);
4newtype (tuple struct) around one existing typehook_state!(AccountState, AccountKey AccountId => AccountData {balance: XFL, sequence: u16});
existingkey impls on a key type you declaredhook_state!(MyOwnState, existing MyOwnKey = b"MK" => u64);
pairingwraps a key type you already declared, that already encodeshook_state!(MyState, MyKey => MyValue);

Every form declares the entity first — the thing your hook operates on, and the only thing that gets the four accessor methods. The key component gets no methods of its own; it’s a trait carrier you hand to the free functions when you want the address rather than the thing addressed. The value side (after =>) accepts either an already-declared type or an inline definition (=> Name { field: Type, .. }), which generates a fresh #[derive(HookData)]-equivalent struct.

Form 1: fully fixed key

$Entity and $Name both become new unit structs — zero-sized markers whose own name is the one value — encoding the same fixed, literal bytes either way:

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

hook_state!(RewardRate, RewardRateKey = b"RR" => XFL);

let current = RewardRate.get_state()?;
RewardRate.set_state(&XFL::one())?;

Form 3: struct key, constructed per call site

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

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

hook_state!(DepositState, DepositKey {tag: u8, owner: AccountId} => Deposit {amount: u64, deadline: u32, flags: u8});

let deposit = DepositState { tag: 1, owner: AccountId::default() };
let current = deposit.get_state()?;
deposit.set_state(&Deposit { amount: 1, deadline: 0, flags: 0 })?;

get_state/set_state/update_state/delete_state are #[inline(always)] forwards to state_get_typed(&deposit)/state_set_typed(&deposit, &v)/etc — the method call and the free-function call compile to identical code, so the choice is purely about which reads better at the call site.

The pairing form: entities over derives you already wrote

When you’ve already declared #[derive(HookKey)]/#[derive(HookData)] types yourself (see Typed Data with Derives), the pairing form ties them together without redeclaring anything:

use rshooks::prelude::*;
use rshooks::{hook_state, HookData, HookKey};

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

#[derive(HookData, Clone, Copy, Debug, PartialEq)]
struct MyValue {
    count: u32,
}

hook_state!(MyState, MyKey => MyValue);

let value = MyState(MyKey { tag: 0 }).get_state()?;

$Key must be local to your crate (Rust’s orphan rule — a bare [u8; N] or types::StateKey needs Form 4’s newtype wrapper instead), already able to encode itself (StateKeyEncode, from #[derive(HookKey)] or state_keys!), and not already paired with another value type. A state_keys! enum — which has StateKeyEncode but no ToBytes — pairs just as well as a #[derive(HookKey)] struct, since the entity forwards encode() straight through rather than re-deriving it.

For the remaining forms (2, 4, and existing) and every edge case — the visibility rules, why deletion (delete_state) needs its own spelling rather than an empty-value write, and the full compile-time error messages for a misused pairing — see rshooks::hook_state!’s own rustdoc, which is the canonical reference this section summarizes.

The counter walkthrough

examples/02_state-counter is the smallest complete tutorial for the typed layer, using Form 2 (a struct key with a fixed instance):

hook_state!(Counter, CounterKey {name: [u8; 7]} = {name: *b"counter"} => u64);

#[hook]
fn my_hook() -> i64 {
    let count = Counter.get_state().unwrap_or(Some(0)).unwrap_or(0);

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

    accept!(b"state-counter: incremented", next as i64)
}

One line declares Counter (the entity, with the four accessors), const Counter: Counter = Counter { .. } (the fixed instance — legal because a type name and a value name live in separate namespaces), and CounterKey (the key component). Counter.get_state() 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.

CounterKey { name: *b"counter" } sends exactly the same 7 bytes a bare *b"counter" array key would (see “Key length and padding” above) — the struct wrapper exists only to satisfy the orphan rule for the generated TypedStateKey impl, not to change what’s on the wire.

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. state_delete (and the generated delete_state() method) 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_state().is_err() {
    rollback!(
        b"typed-data: state_set failed",
        TypedDataError::StateSetFailed
    );
}

Foreign state: reading another account’s entries

state_foreign/state_foreign_get/state_foreign_get_typed (and their set/update twins) read or write a state entry belonging to another account, or another namespace on this hook’s own account. namespace and account both default to “this hook’s own” when passed None; when present, they’re a bare reference (&target), not Some(&target) — a generic Option<...> parameter can’t also accept a bare None literal without becoming ambiguous, so rshooks uses a small ForeignRef trait instead that accepts either shape directly.

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

hook_parameter!(AcctParam, AcctParamName = b"ACCT" => AccountId);

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

#[hook]
fn my_hook() -> i64 {
    let Ok(target) = AcctParam.get_value() else {
        rollback!(
            b"state-foreign: ACCT parameter not configured",
            StateForeignError::AcctNotConfigured
        )
    };

    let mut flag = [0u8; 1];
    match state_foreign(&mut flag, &ENABLED_KEY, None, &target) {
        Ok(n) if n == flag.len() => {}
        Err(HookError::DoesntExist) => rollback!(
            b"state-foreign: not configured on target account",
            StateForeignError::NotConfiguredOnTarget
        ),
        _ => 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
        );
    }

    accept!()
}

Passing namespace = None and account = &target 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.” Note this example reads the raw entry directly via state_foreign rather than the typed state_foreign_get_typed: the typed layer decodes a lenient prefix, not an exact length, so it would silently tolerate an oversized enabled value this raw code correctly rejects by checking n == flag.len(). When your value type’s exact length matters, decide deliberately between the raw and typed foreign accessors rather than reaching for the typed one by default.

Where to go next

Every typed value type on this page — the u64 in the counter example, the Deposit/DepositValue structs, AccountId as a Balance key payload — 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.

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_parameter!/otxn_parameter! entity macros 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
entity macrohook_parameter!otxn_parameter!
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 TypedParamName trait 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. examples/03_hook-params uses exactly this for a compiled-in default when the operator hasn’t configured a minimum:

const MIN_PARAM: &[u8] = b"MIN";
const DEFAULT_MIN_DROPS: u64 = 1_000_000;

fn min_drops() -> u64 {
    hook_param_exact(MIN_PARAM)
        .map(u64::from_be_bytes)
        .unwrap_or(DEFAULT_MIN_DROPS)
}

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, conventionally matching Xahau Binary’s big-endian numeric encoding for a value like this, the same convention Reading the Originating Transaction describes for raw protocol fields. .unwrap_or(DEFAULT_MIN_DROPS) 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.

The typed pairing: hook_param_typed/otxn_param_typed

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"INS") 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).

TypedParamName closes that gap: implement it for a name type — directly, or via hook_parameter!/otxn_parameter! — to declare its one paired value type once, then call hook_param_typed/otxn_param_typed with a reference to a name value. The return type is resolved from the name argument itself, never a turbofish and never an independently-chosen type a mismatch could hide behind.

The entity macros: hook_parameter!/otxn_parameter!

These declare a parameter entity — the thing your hook reads — using the identical grammar staircase hook_state! uses for hook state (see Hook State):

formname shapeexample
1fully fixed (a new zero-sized type)hook_parameter!(Cfg, CfgName = b"CFG" => Config);
2struct, with a fixed instancesame shape as hook_state!, applied to a name instead of a key
3struct, constructed per call sitehook_parameter!(SeatVote, SeatParamName {topic: u8, seat: u8} => Vote);
4newtype (tuple struct) around one existing typesame shape as hook_state!
existingname impls on a name type you declaredhook_parameter!(Cfg, existing CfgName = b"CFG" => Config);
pairingwraps a name type you already declared, that already encodeshook_parameter!(SeatVote, SeatParamName => Vote);

otxn_parameter! has the exact same grammar; the only difference is which host call the generated get_value() forwards to (hook_param_typed vs. otxn_param_typed), so the declaration site itself documents which of the two a parameter is meant for.

Form 1, with the compiled-in-default pattern

examples/03_hook-params’s MIN parameter, expressed the typed way:

use rshooks::prelude::*;
use rshooks::{ParamValue, hook_parameter};

hook_parameter!(Cfg, CfgName = b"CFG" => Config {min_amount: u64});

fn min_amount() -> u64 {
    Cfg.get_value().map(|c| c.min_amount).unwrap_or(1_000_000)
}

Form 1 declares both Cfg (the entity) and CfgName (the name component) as new zero-sized types, both encoding the literal b"CFG". Cfg.get_name() -> &'static [u8] is available as a const fn returning that literal directly — no encode step at all — because a plain byte-string name has nothing to compute: its wire encoding is its in-memory representation. Cfg.get_value() is an #[inline(always)] forward to hook_param_typed(&CfgName); the two spellings compile to the same code, so the choice is purely readability.

.unwrap_or(default) is the idiomatic way to give a hook a sensible compiled-in fallback: get_value() returns Err uniformly whether the parameter was never set or was set with the wrong byte length, so one unwrap_or handles “unconfigured” and “malformed” the same way, exactly like the loose hook_param_exact pattern above.

Composite names: #[derive(ParamName)]

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 below) for a named-field struct used this way. examples/12_typed-data’s typed-data hook uses this for an operator-controlled pause switch, the same idea xahaud’s own genesis governance hook uses for its IS0..IS19 seat parameters:

use rshooks::prelude::*;
use rshooks::{ParamName, ParamValue, hook_parameter};

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

hook_parameter!(AdminPause, AdminName => PauseSwitch {paused: u8});

const ADMIN_PAUSE: AdminPause = AdminPause(AdminName {
    section: 0,
    field: 0,
});

fn deposits_paused() -> bool {
    ADMIN_PAUSE
        .get_value()
        .map(|s| s.paused != 0)
        .unwrap_or(false)
}

This is hook_parameter!’s pairing form — an entity wrapping a name type already declared with #[derive(ParamName)], paired with an inline PauseSwitch value (#[derive(ParamValue)]-equivalent codegen, generated inline here). 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 TypedParamName impl has to actually encode at runtime — laying section and field out into a small buffer sized exactly to AdminName::MAX_LEN (not the full 32-byte scratch the trait’s generic default would need, since only a concrete, non-generic impl can size an array by an associated constant). examples/12_typed-data’s README measures this directly: +29 worst-case instructions over the same hook without the composite name, versus the near-zero cost of the plain CFG/INS tags used elsewhere in that same hook.

Why the typed pairing 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. Every hook_parameter!/otxn_parameter! declaration removes that degree of freedom: the name type is permanently tied to exactly one value type (TypedParamName::Value), so Cfg.get_value() and Ins.get_value() in examples/12_typed-data can never accidentally decode one parameter’s bytes as the other’s struct shape — the compiler resolves the return type from the entity itself, with no independently-chosen type left for a mismatch to hide in. This is the identical safety property Hook State’s hook_state! gives the key/value side; see Typed Data with Derives for the underlying ParamName/ParamValue derives both macros build on.

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 a composite hook-state key/value pair and two composite parameter name/value pairs, each in one line, via the entity macros covered in Hook State and Hook and Transaction Parameters:

// Per-account deposit record: a tag byte + AccountId key, an
// amount + deadline + flags value.
hook_state!(DepositState, DepositKey {tag: u8, owner: AccountId} => DepositValue {amount: u64, deadline: u32, flags: u8});

// Install-time configuration.
hook_parameter!(Cfg, CfgName = b"CFG" => Config {min_amount: u64, lock_ledgers: u32});

// Per-invocation instruction, attached to the triggering Invoke transaction.
otxn_parameter!(Ins, InsName = b"INS" => Instruction {action: u8, amount: u64});

Under the hood, each declaration expands to the same narrow derives this page describes — DepositKey gets HookKey-equivalent codegen, DepositValue/Config/Instruction get HookData/ParamValue-equivalent codegen — plus the pairing trait (TypedStateKey/TypedParamName) that ties key/name to value. Used directly, with no manual byte packing anywhere:

let deposit = DepositState { tag: DEPOSIT_TAG, owner };
let current = deposit.get_state()?.unwrap_or(EMPTY_DEPOSIT);
// ...
deposit.set_state(&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’s README backs this with a real rshooks build/check measurement: this hook’s core deposit-ledger logic, built twice — once with the derives as committed, once with all four hand-packed instead, everything else byte-for-byte identical:

versionworst-case instructionswasm size
derived (as committed)4411504 bytes
hand-packed5251674 bytes

The derived version isn’t just as cheap — it measures cheaper: 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; no --auto-guard/--default-maxiter needed for either.

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 (+29 worst-case instructions in that example) — see that page’s “Composite names” section for the number and 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() is the only constructor guaranteed not to fail. XFL::new(exponent, mantissa) builds a normalized value from its two components:

let min_share = XFL::new(-21, 1_000_000_000_000_000)?;

(from examples/07_xfl-math). 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.

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. There is no local arithmetic: self + rhs issues a float_sum host call, self * rhs issues float_multiply, and so on. Sub is built from Neg plus float_sum (there is no dedicated float_subtract host function), and Neg is a real float_negate round trip, never a local sign-bit flip.

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).

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

.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 { currency: CurrencyCode, issuer: AccountId }, // 40 bytes
}

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.

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! clears each intermediate as soon as its child exists, so a 10-hop path costs one live slot, not ten:

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); every intermediate is cleared unconditionally, before its result is inspected, so a hop that fails cannot leak the parent that produced it.

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 260-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 260 iterations — including a separate 260-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_slotslot_subfieldslot_exact) into the typed equivalent and built both at this workspace’s opt-level = 3:

versionworst-case instructionswasm size
raw, numbered slots, no clears197925 bytes
typed, no clears197925 bytes
raw, numbered slots + 3 slot_clear209965 bytes
typed + 3 clears via take_*219980 bytes

The first two rows — the apples-to-apples comparison, same host calls, same cleanup policy — are byte-identical: every typed wrapper is #[inline(always)] over the same host call, so the type layer adds nothing. The bottom two rows aren’t directly comparable to each other: 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 extra ten instructions buy strictly stronger cleanup rather than being layer overhead.

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 — examples/80_reward and examples/81_govern both do this deliberately. 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, 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. Every one is a thin #[inline(always)] pass-through to the same underlying host call, so this costs nothing beyond the raw call itself.

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

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 state_set(keylet.as_ref(), &KeyletKey::Account.encode()).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.

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 #[cbak], 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 hook’s wasm module exports #[cbak], 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: u32_field(sfFlags) = tfCANONICAL,
        source_tag: u32_field(sfSourceTag) = 0,
        sequence: u32_field(sfSequence) = 0,
        destination_tag: u32_field(sfDestinationTag) = 0,
        first_ledger_sequence: u32_field(sfFirstLedgerSequence) = 0,
        last_ledger_sequence: u32_field(sfLastLedgerSequence) = 0,
        amount: native_amount(sfAmount) = 0,
        fee: native_amount(sfFee) = 0,
        signing_pub_key: empty_vl(sfSigningPubKey),
        account: account_id(sfAccount),
        destination: account_id(sfDestination),
        emit_details: emit_details,
    }
}

(from examples/10_emit-txn.) Each field uses one of four uniform kinds — u32_field(sfXxx) = default, native_amount(sfXxx) = default (always a u64 drops value), account_id(sfXxx) (defaults to all-zero), or empty_vl(sfXxx) (an empty variable-length blob, no setter generated, since there’s nothing to set) — plus the structural emit_details marker, which must be declared last and reserves space for the host’s own EmitDetails field with no header of its own.

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 set_<field> method per u32_field/native_amount/account_id field:

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

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).

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]: reacting to the outcome

A hook module can optionally export cbak, generated by the same #[cbak] attribute macro that produces hook from #[hook]. The host invokes it later — in a separate execution — when a transaction this hook previously emitted settles on ledger, whether it succeeds or bounces:

#[cbak]
fn my_cbak() -> i64 {
    accept!()
}

(from examples/10_emit-txn; a real callback typically inspects the settled transaction’s metadata via SlotObject::from_meta() — see Slots and Ledger Objects — before deciding how to react.) Exporting #[cbak] changes EmitDetails’s 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.

HookCanEmit in hook metadata

Emitting a transaction of a given type is itself a capability a hook must declare. metadata!’s HookCanEmit list names every transaction type this hook’s wasm module might emit:

metadata! {
    name: "emit-txn",
    description: "Emits a Payment and handles its callback.",
    HookOn: [Invoke],
    HookCanEmit: [Payment],
    HookName: "emit-tx",
}

Omitting a type from HookCanEmit that the hook actually tries to emit is rejected before the emission can happen. See Hook Metadata for the full metadata! reference, including how HookCanEmit interacts with HookOn and the other declarative fields.

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 Rust crate for wasm32v1-none (cargo build --release --target wasm32v1-none), then cleans and validates the result into a SetHook-legal binary. This is the pipeline described in Building a Hook.

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 cargo build.
-p, --package <NAME>noneBuild only the named package, forwarded to cargo build -p. Useful when --manifest-path points at a workspace.
--api-version <0|1>0The Hook API version this module targets. 0 is Guard-type (loop guards required); 1 is Gas-type (guard handling skipped).
--auto-guardoffInsert missing loop guards instead of treating an unguarded loop as a build error.
--default-maxiter <N>16The maxiter value used for auto-inserted guards, when --auto-guard is set.
--out <DIR>out/ next to the manifestDirectory to write the output binary (and metadata sidecar, if any) to.
--allow-oversizeoffWrite the output even if it exceeds the 65,535-byte SetHook size limit. The result is still clearly marked invalid in the printed report.

On success, build writes out/<crate>.wasm (matching cargo’s own artifact file name) and, if the crate declares metadata!, a matching out/<crate>.json sidecar — see Hook Metadata. A stale sidecar from a previous build that no longer declares metadata! is removed automatically.

rshooks clean

Cleans and validates an already-built wasm file directly, without invoking cargo. Useful for post-processing an artifact you already have on disk — for example one built by a different pipeline, or one you want to reprocess with different flags without rebuilding.

rshooks clean path/to/artifact.wasm
flagdefaultdescription
input (positional)The input wasm file. Required.
-o, --out <PATH><input>.clean.wasmWhere to write the cleaned binary.
--api-version <0|1>0The Hook API version this module targets.
--auto-guardoffInsert missing loop guards instead of treating them as an error.
--default-maxiter <N>16maxiter used for auto-inserted guards.
--allow-oversizeoffWrite the output even if it exceeds the 65,535-byte SetHook limit.

clean does not generate a metadata sidecar — that step is specific to build, since it needs the original crate’s metadata! carrier from cargo’s raw artifact.

rshooks check

Validates a wasm file against the full SetHook rule set without modifying it. Unlike build/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.
--api-version <0|1>0The Hook API version this module targets.

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.

Hook Metadata

A Hook crate can declare a metadata! block describing itself — its name, description, trigger transaction types, and on-ledger HookName. rshooks build reads this declaration and writes a JSON sidecar next to the compiled wasm, combining what you wrote with facts only available after the build (the binary’s hash and its worst-case instruction counts). This page covers the full grammar and the sidecar’s exact shape.

Declaring metadata

use rshooks::metadata;

metadata! {
    name: "payment observer",
    description: "Observes incoming and outgoing payments.",
    HookOn: [Payment, Invoke],
    HookCanEmit: [Payment],
    HookName: "pay-hook",
}
  • name — required, a non-empty display name for the Hook.
  • description — optional, free-form text.
  • HookOn — optional, a list of bare TxType variant names (see below) that trigger this Hook in both directions.
  • HookCanEmit — optional, the transaction types this Hook declares it may emit.
  • HookName — optional, the UTF-8 string placed in SetHook’s HookName field.

At most one metadata! declaration should appear in a Hook crate. It can sit anywhere at module scope in src/lib.rs — it doesn’t need to be referenced by hook or cbak to take effect.

Directional triggers: IncomingHookOn / OutgoingHookOn

HookOn is mutually exclusive with a directional form, where both arrays are required together:

metadata! {
    name: "directional hook",
    IncomingHookOn: [Payment, Invoke],
    OutgoingHookOn: [Payment],
}

A crate may declare HookOn alone, IncomingHookOn and OutgoingHookOn together, or omit all three trigger fields entirely — any other combination (for example HookOn alongside IncomingHookOn, or IncomingHookOn without OutgoingHookOn) is rejected at build time. IncomingHookOn and OutgoingHookOn must also describe genuinely different sets of transaction types; if they’d end up identical, the build rejects it and asks you to use plain HookOn instead, since that’s what it means. When all three are omitted, the sidecar represents the resulting all-zero raw HookOn value as null.

Transaction type names

Every entry in HookOn, IncomingHookOn, OutgoingHookOn, and HookCanEmit 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.

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.

How metadata travels through the build

metadata!’s expansion carries the declaration as compact JSON, hex- encoded into the name of a Hook wasm export that is never actually called — a deliberately dead export named __rshooks_metadata_v1_<HEX>. rshooks build reads this carrier from cargo’s raw artifact, before cleaning, and the ordinary hook-cleaner pass then removes it along with every other non-hook/cbak export. The declaration is build-only and never changes the deployed binary: it adds no data segment, no runtime code, no import, and no byte to the final wasm — the same HookHash and WCE would result whether or not metadata! was present at all.

The JSON sidecar

For a Hook declaring:

metadata! {
    name: "accept-all",
    description: "Accepts every transaction selected by HookOn.",
    HookOn: [Invoke],
    HookName: "accept",
}

rshooks build writes an out/<crate>.json sidecar shaped like this (real output, from the accept-all example):

{
  "name": "accept-all",
  "description": "Accepts every transaction selected by HookOn.",
  "HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
  "HookCanEmit": null,
  "HookName": "616363657074",
  "HookHash": "DCE6A3F81224AE89C557F04D73420D808D9009BCF1CFC1474396CD2DA2D4DF16",
  "WCE": {
    "hook": 15,
    "cbak": 0
  },
  "builder": {
    "name": "rshooks-build",
    "version": "0.0.1",
    "rustc": "rustc 1.89.0 (29483883e 2025-08-04)"
  },
  "human": {
    "HookOn": [
      "Invoke"
    ],
    "HookCanEmit": null,
    "HookName": "accept"
  }
}
  • Top-level HookOn/HookOnIncoming/HookOnOutgoing — the raw, deployable SetHook value: a 32-byte hex string encoding Xahau’s inverted transaction-type bitmask (every bit set except the ones corresponding to the transaction types you listed). This is the exact bytes a SetHook transaction’s HookOn field expects; null when no trigger fields were declared. HookOnIncoming/HookOnOutgoing appear instead of HookOn when the source used the directional form.
  • HookCanEmit — the same bitmask encoding, or null if omitted.
  • HookName — the declared name’s raw UTF-8 bytes as uppercase hex ("accept"616363657074), matching what SetHook expects on the wire. null if HookName wasn’t declared.
  • HookHash — Xahau’s hash of the deployed binary: the uppercase hex of the first 32 bytes of the final cleaned wasm’s SHA-512 digest. This identifies the exact Hook code, independent of which account installs it.
  • WCE — the same worst-case-execution figures printed to the terminal during the build: static instruction-count upper bounds for hook and cbak, or null for both on a Gas-type (--api-version 1) module, which has no static bound of this kind.
  • builder — provenance of the toolchain that produced this sidecar: the tool’s package name and version, and the full first line of rustc -V from the compiler that performed the build (null if it couldn’t be detected). Optimization behavior can change across toolchain updates even when the source doesn’t, so recording exactly which compiler produced a given HookHash/WCE pair is what lets a build be reproduced deterministically later.
  • human — the readable, source-level form of every field above: transaction type names as written, and the HookName string itself rather than its hex encoding. Use human to review what a sidecar declares; use the top-level fields when constructing an actual SetHook transaction.

Two consistency checks run at build time and surface as warnings (not hard errors) in the sidecar generation step: declaring HookCanEmit when the final wasm never actually calls the emit API, and calling emit without having declared HookCanEmit at all.

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.

Entry points

macropurposesketch
#[hook]Turns a plain fn name() -> i64 into the required wasm hook export.#[hook] fn my_hook() -> i64 { accept!() }
#[cbak]Same as #[hook], but exports cbak — the optional callback invoked when a transaction this hook emitted later settles.#[cbak] fn my_cbak() -> i64 { accept!() }

See Anatomy of a Hook 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.hook_errors! { pub enum E { BlockedAccount = 1 } }
exit_on_err!Unwrap a Result<T, E: Into<i64>>, rolling back on Err.let v = exit_on_err!(b"failed", check());

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

Data & typing

macropurposesketch
#[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 }
hook_state!Declare a hook-state entity — key + value pairing, with get_state/set_state/update_state/delete_state accessors. Six grammar forms.hook_state!(DepositState, DepositKey {tag: u8} => Deposit {amount: u64});
state_keys!Declare an enum of hook-state keys, each variant its own real byte length.state_keys! { enum DataKey { Counter, Balance(AccountId) } }
hook_parameter!Declare a Hook API parameter (this hook’s own installed parameters) — name + value pairing, get_value (and get_name for byte-string names). Same grammar staircase as hook_state!.hook_parameter!(Cfg, CfgName = b"CFG" => Config);
otxn_parameter!Identical to hook_parameter!, but reads the originating transaction’s parameters via otxn_param_typed.otxn_parameter!(Ins, InsName = b"INS" => Instruction);

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, clearing each intermediate handle as soon as its child exists — 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

macropurposesketch
metadata!Declare a Hook’s descriptive/SetHook-facing metadata (name, HookOn, HookCanEmit, …) for rshooks to extract into a sidecar JSON. Build-only — adds nothing to the final wasm.metadata! { name: "accept-all", HookOn: [Invoke], HookName: "accept" }

See Hook Metadata.

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).
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". See Guards and Loops.

Convert traits

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

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.

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 backing hook_state!, 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.

TxType

crate::tx_type::TxType — the typed transaction-type enum (TxType::Payment, …), used by otxn_type and by metadata!’s HookOn/HookCanEmit lists. See Reading the Originating Transaction.

Types

crate::types::* — protocol value newtypes: AccountId, Hash, StateKey, NameSpace, CurrencyCode, 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::*, ls_flags::*, tts::*, tx_flags::*} — the C-verbatim constant tables: KEYLET_*/COMPARE_* (consts), 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 two not re-exported here (sfcodes, error).

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 rshooksapi, 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::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.

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, built with rshooks (from the rshooks-build package) — its own Cargo workspace, separate from the root workspace, because these crates are no_std cdylibs with a Hook-specific release profile that must not leak into rshooks-core/rshooks/ rshooks-build, and they don’t build for host targets. Every code sample in this book is adapted from one of these.

Reading order: 01–15

Numbered in suggested reading order — start at 01_accept-all and work down; each one builds on ideas from the examples before it. The example column is each crate’s actual package name (Cargo package names can’t start with a digit, so only the directory is prefixed).

#exampledemonstratesbook chapter
01accept-allminimal hook: accept everything (starter template)Anatomy of a Hook
02state-counterstate/state_set round-trip, counter in hook stateHook State
03hook-paramshook_param-configurable threshold, with a compiled-in defaultHook and Transaction Parameters
04errorsa meaningful hook_errors!-based rollback error-code system, matched to HookReturnCodeAccept, Rollback, and Errors
05firewallread otxn_field(sfAccount) + a hook parameter blacklist → rollbackReading the Originating Transaction
06guard-patternsguard!/guard_m! correctness, choosing maxiter, and the array-== memcmp-loop pitfallGuards and Loops
07xfl-mathreading Amount as XFL (slot_float/sto_set), mulratio, checked Add/Sub/Mul/Div/Neg operators, .compare()-family methods, and XFLUnchecked’s hot-path chainXFL: Decimal Floating Point
08slot-ledgerthe typed slot layer: SlotObject::from_otxn().get(sfXxx).value(), with no slot numbers in sight, measured against the raw numbered API it replacedSlots and Ledger Objects
09state-foreignstate_foreign: reading another (hook-parameter-configured) account’s hook stateHook State
10emit-txnetxn_reserve + a txn_template!-declared Payment/emit, with a cbakEmitting Transactions
12typed-data#[derive(HookData)]: composite (multi-field) state keys/values and otxn_param/hook_param structs, in place of hand-packed byte buffersTyped Data with Derives
13keyletsrshooks::api::keylet’s 26 typed keylet_xxx helpers (one per KEYLET_* constant), in place of the single untyped util_keyletKeylets
14account-id-macrorshooks::account_id!: compile-time r-address → AccountId decode, cross-checked against hook_account/util_accid/util_raddrReading the Originating Transaction
15slot-objectsthe typed slot layer’s live acceptance harness: account-root walk, native-amount drops round-trip, parent-clear/child-read, and two 300-iteration loops proving take_* recycling and leak-free slot_path! failuresSlots and Ledger Objects

There is no 11 — the numbering follows the historical example order, with gaps where an example was retired.

80+: production hooks in Rust

Unlike 0115 (one concept each, in suggested reading order), the 80+ series are behavior-equivalent Rust ports of real, deployed xahaud C hooks — read them after 0115, not instead of them. Each has its own README with a full behavior-equivalence table against its C source, a differences table for any intentional deviation, and a “Toolchain limitation” section documenting a real Guard-type nesting-depth/floating-point constraint discovered while porting them.

#exampleports
80rewardhook/genesis/reward.c — the RewardHook: computes and emits a GenesisMint crediting ClaimReward claimants and active-validator L1 seats
81governhook/genesis/govern.c — the GovernanceHook: the 20-seat L1/L2 round-table governance state machine

Building

Build every example (this is also the toolchain’s own end-to-end test: each one is built via cargo run -p rshooks-build -- build ... from the root workspace, and the resulting out/<name>.wasm is re-validated with rshooks check):

mise run build-examples

Build a single example directly:

cargo run -p rshooks-build -- build --manifest-path examples/02_state-counter/Cargo.toml

See The rshooks CLI for the CLI itself, and each example’s own README for its exact command.

E2E tests

e2e/ deploys the examples’ rshooks-build output to a real, standalone xahaud node (via SetHook) and asserts on the resulting transaction metadata and ledger state — proof of runtime behavior, not just that the binaries are SetHook-valid.