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:
| crate | description |
|---|---|
rshooks-core | no_std, zero-logic FFI layer: raw Hook API declarations and every constant from the xahaud hook/ headers, translated 1:1 into Rust. |
rshooks-macros | Procedural macros for rshooks (#[hook]/#[cbak], metadata!, XFL literals, and more). |
rshooks | no_std, ergonomic wrapper over rshooks-core — Result-based APIs, typed buffers, the XFL decimal-float type, guard/trace macros, and a panic handler. |
rshooks-build | The 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-allexample, 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
XFLdecimal-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
rshooksCLI itself and themetadata!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 artifactrshookspost-processes into a.wasmbinary. Plaincargo buildoutput from acdylibalso exportsmemory, whichrshooksstrips along the way (SetHook rejects a module that exports anything besideshook/cbak).- The crate itself is
# — there is no allocator, nostd, 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 unguardedmemset-style loop, which avoids a class of build failures the guard checker would otherwise reject.lto = "fat",codegen-units = 1,panic = "abort", andstrip = "symbols"all reduce final binary size, which matters directly: SetHook’s fee scales with the deployed binary’s byte count. This mirrors the profile theexamples/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 rawcdyliboutput with thememoryexport stripped and every Hook API rule (§ singlehook/cbakexport, guarded loops, MVP-only instructions) validated.out/my_hook.json— the metadata sidecar, generated because this crate declaredmetadata!. Foraccept-allspecifically, 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:
cargo build --release --target wasm32v1-none— compiles your crate exactly as any other Rust crate would be, using yourCargo.tomland its[profile.release]settings (see Installation for why that profile matters). The output is an ordinarycdylibartifact; on its own it is not SetHook-valid — it still exportsmemory, and Rust’s own code generation gives no guarantee about loop guards or WASM feature usage.- Hook-cleaner — strips the disallowed
memoryexport and any other dead or non-hook/cbakexports, and (for Guard-type, API version 0, modules) flattens and inlines the crate’s call graph into thehook/cbakentry points, untangling the resulting block/loop/if nesting so it fits the host’s structural limits. This is also the stage that strips ametadata!declaration’s carrier export — see Hook Metadata for why that carrier never reaches the deployable binary. - 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
hookand, 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). - Validator — checks the complete SetHook rule set: exactly one
hookexport (and at most onecbak), no disallowed imports, no recursion, and a binary size at or under the 65,535-byte SetHook limit (unless--allow-oversizeis passed, in which case the output is still written but clearly marked invalid). - Metadata sidecar — if the crate declared
metadata!, writes the<crate>.jsonsidecar next to the cleaned wasm, combining the source declaration with the final binary’sHookHashand 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 instructionsis 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 depthis the deepest block/loop/if nesting in the final module, checked against the host’s structural limit.size/estimated SetHook feeare computed directly from the final binary’s byte count — SetHook’s fee schedule isbytes × 5000drops, 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, nostd::anything.rshooks’spreludemodule gives you the ergonomic surface (typed accessors, macros, common types) without depending onstd.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, orexternmodifiers; - no generics, no
whereclause.
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 returnOption, instead) — a literal, provably in-bounds index on a fixed-size array is fine. - No
format!/core::fmt—trace!,accept!, androllback!all take raw byte slices, not formatted strings. - No
unwrap/expect/panic!— everyResultis handled explicitly, typically by rolling back onErr. - 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 covers how a hook actually terminates, and how to give it a meaningful error-code system.
- Guards and Loops covers the guard system that every loop — hand-written or compiler-generated — must satisfy.
- Reading the Originating Transaction and Hook State cover the data-access APIs a hook’s body actually calls.
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 prefererr.code()overi64::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
| Code | Reason | Message |
|---|---|---|
0 | (via accept!()) | every check passed |
-101 | BadAccountField | errors: could not read otxn Account |
-102 | BlockedSourceTag | errors: blocked SourceTag |
-103 | NotNativeAmount | errors: unsupported (non-native) Amount |
-104 | AmountTooLarge | errors: 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 covers the other hard constraint every hook must satisfy: the loop-guard system.
- Reading the Originating Transaction covers the
otxn_field_*calls used in the worked example above. - Macro Reference is the full grammar listing for every macro this page uses.
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 abcmp-style byte-compare loop. - Large buffer zero-init or copy — a big stack-local
[0u8; N], or a largememcpy-shaped copy, lowers to amemset/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 equality — rshooks::buf_eq_8/_20/_32/_33/
_34/_40/_48/_64 compare a buffer as a fixed sequence of word-sized
(u64, with a narrower tail word where the size isn’t a multiple of 8)
chunks, built from source-level literal byte indices. The comparison is
genuinely straight-line code — there is nothing for LLVM to lower into a
loop:
if buf_eq_20(&sender, &blocked) {
rollback!(
b"guard-patterns: blocked account",
GuardPatternsError::BlockedAccount
);
}
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
HookStaticidiom in full, including the safety argument for its take-once exclusivity. - Accept, Rollback, and Errors covers how a hook actually terminates once its checks — guarded loops included — are done.
- The rshooks CLI covers
--auto-guardand--default-maxiteras 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
- Accept, Rollback, and Errors covers
accept!/rollback!, the macros that sharetrace!’s raw-byte-slice message grammar. - Guards and Loops covers the other place a hook’s instruction budget matters.
- Macro Reference is the full grammar listing for every macro on this page.
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:
| form | key shape | example |
|---|---|---|
| 1 | fully fixed (a new zero-sized type) | hook_state!(RewardRate, RewardRateKey = b"RR" => XFL); |
| 2 | struct, with a fixed instance | hook_state!(Counter, CounterKey {name: [u8; 7]} = {name: *b"counter"} => u64); |
| 3 | struct, constructed per call site | hook_state!(DepositState, DepositKey {tag: u8, owner: AccountId} => Deposit); |
| 4 | newtype (tuple struct) around one existing type | hook_state!(AccountState, AccountKey AccountId => AccountData {balance: XFL, sequence: u16}); |
existing | key impls on a key type you declared | hook_state!(MyOwnState, existing MyOwnKey = b"MK" => u64); |
| pairing | wraps a key type you already declared, that already encodes | hook_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 parameter | Otxn parameter | |
|---|---|---|
| set by | the hook’s operator, at SetHook time | whoever submits the triggering transaction |
| read with | hook_param/hook_param_exact/hook_param_typed | otxn_param/otxn_param_exact/otxn_param_typed |
| entity macro | hook_parameter! | otxn_parameter! |
| typical use | operator-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):
| form | name shape | example |
|---|---|---|
| 1 | fully fixed (a new zero-sized type) | hook_parameter!(Cfg, CfgName = b"CFG" => Config); |
| 2 | struct, with a fixed instance | same shape as hook_state!, applied to a name instead of a key |
| 3 | struct, constructed per call site | hook_parameter!(SeatVote, SeatParamName {topic: u8, seat: u8} => Vote); |
| 4 | newtype (tuple struct) around one existing type | same shape as hook_state! |
existing | name impls on a name type you declared | hook_parameter!(Cfg, existing CfgName = b"CFG" => Config); |
| pairing | wraps a name type you already declared, that already encodes | hook_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:
| derive | role | generates | can be read back? |
|---|---|---|---|
HookData | hook-state value | ToBytes + FromBytes + FixedRead + LEN | yes |
HookKey | hook-state key | ToBytes + StateKeyEncode (≤32-byte check) | no |
ParamName | parameter name | ToBytes only (1–32-byte check) | no |
ParamValue | parameter value | FromBytes + FixedRead only | yes (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_paramto locate something — never read back and decoded as itself.HookKey/ParamNamereflect that by generating noFromBytes/FixedRead/LENat 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.ParamValuespecifically generates noToBytes, since a hook never writes its own parameters (hook_param_setwrites a different hook’s parameter, taking a raw&[u8], not a typed value). - Only
HookKey, astate_keys!enum, ortypes::StateKeyimplementsStateKeyEncode— an ordinaryHookDatavalue 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 forParamNamevs.ParamValue. HookKeyandParamNameeach carry a size bound the Hook API itself imposes, checked at the struct’s own definition, before it’s ever used:HookKeyrejects anything over 32 bytes (a hook-state key’s fixed space);ParamNamerejects 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/ParamValuehave no such cap — a state value or parameter payload isn’t limited that way (beyondrshooks::state’s own 32-byte typed-storage convenience limit, which the rawapi::statefunctions 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:
| version | worst-case instructions | wasm size |
|---|---|---|
| derived (as committed) | 441 | 1504 bytes |
| hand-packed | 525 | 1674 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 owni64expansion, 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-81to1e96(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 transactionSlotObject::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_slot → slot_subfield → slot_exact) into the typed
equivalent and built both at this workspace’s opt-level = 3:
| version | worst-case instructions | wasm size |
|---|---|---|
| raw, numbered slots, no clears | 197 | 925 bytes |
| typed, no clears | 197 | 925 bytes |
raw, numbered slots + 3 slot_clear | 209 | 965 bytes |
typed + 3 clears via take_* | 219 | 980 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
| function | KEYLET_* | 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
- Reserve. Call
etxn_reserve(count)before building or emitting anything — it tells the host how many transactions this invocation intends to emit, and everyemitcall after that must stay within the reserved count. - 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. - Emit. Hand the finished bytes to
emit, which returns the emitted transaction’s hash. - 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 field | sfcode | kind |
|---|---|---|
| Sequence | sfSequence | u32_field |
| FirstLedgerSequence | sfFirstLedgerSequence | u32_field |
| LastLedgerSequence | sfLastLedgerSequence | u32_field |
| Fee | sfFee | native_amount |
| SigningPubKey | sfSigningPubKey | empty_vl |
| Account | sfAccount | account_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:
- Reads the current ledger sequence and writes
FirstLedgerSequence = ledger_seq + 1,LastLedgerSequence = FirstLedgerSequence + 4. - Writes
Accountfromhook_account(). - Calls
etxn_details()into the reservedEmitDetailsregion 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. - Slices the template to exactly
emit_details offset + returned length, callsetxn_fee_base()over that real slice, and writesFee. - 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
| flag | default | description |
|---|---|---|
--manifest-path <PATH> | cargo’s default (current directory) | Path to the crate’s Cargo.toml, forwarded to cargo build. |
-p, --package <NAME> | none | Build only the named package, forwarded to cargo build -p. Useful when --manifest-path points at a workspace. |
--api-version <0|1> | 0 | The Hook API version this module targets. 0 is Guard-type (loop guards required); 1 is Gas-type (guard handling skipped). |
--auto-guard | off | Insert missing loop guards instead of treating an unguarded loop as a build error. |
--default-maxiter <N> | 16 | The maxiter value used for auto-inserted guards, when --auto-guard is set. |
--out <DIR> | out/ next to the manifest | Directory to write the output binary (and metadata sidecar, if any) to. |
--allow-oversize | off | Write 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
| flag | default | description |
|---|---|---|
input (positional) | — | The input wasm file. Required. |
-o, --out <PATH> | <input>.clean.wasm | Where to write the cleaned binary. |
--api-version <0|1> | 0 | The Hook API version this module targets. |
--auto-guard | off | Insert missing loop guards instead of treating them as an error. |
--default-maxiter <N> | 16 | maxiter used for auto-inserted guards. |
--allow-oversize | off | Write 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
| flag | default | description |
|---|---|---|
file (positional) | — | The wasm file to validate. Required. |
--api-version <0|1> | 0 | The 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 bareTxTypevariant 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’sHookNamefield.
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 aSetHooktransaction’sHookOnfield expects;nullwhen no trigger fields were declared.HookOnIncoming/HookOnOutgoingappear instead ofHookOnwhen the source used the directional form. HookCanEmit— the same bitmask encoding, ornullif omitted.HookName— the declared name’s raw UTF-8 bytes as uppercase hex ("accept"→616363657074), matching whatSetHookexpects on the wire.nullifHookNamewasn’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 forhookandcbak, ornullfor 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 packagenameandversion, and the full first line ofrustc -Vfrom the compiler that performed the build (nullif 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 givenHookHash/WCEpair 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 theHookNamestring itself rather than its hex encoding. Usehumanto review what a sidecar declares; use the top-level fields when constructing an actualSetHooktransaction.
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
| macro | purpose | sketch |
|---|---|---|
#[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
| macro | purpose | sketch |
|---|---|---|
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
| macro | purpose | sketch |
|---|---|---|
#[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
| macro | purpose | sketch |
|---|---|---|
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
| macro | purpose | sketch |
|---|---|---|
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, .. } } |
Slots
| macro | purpose | sketch |
|---|---|---|
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]) |
Tracing & buffers
| macro | purpose | sketch |
|---|---|---|
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
| macro | purpose | sketch |
|---|---|---|
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):
| module | covers |
|---|---|
api::control | accept/rollback (the functions accept!/rollback! call into). |
api::etxn | Transaction emission: etxn_reserve, etxn_fee_base, etxn_details, emit. |
api::float | XFL arithmetic host calls (float_multiply, float_divide, mulratio, …). |
api::hook_ctx | This hook’s own context: hook_account, hook_param, hook_param_typed, hook_param_exact, hook_param_set, and related. |
api::keylet | The 26 typed keylet_xxx helpers (one per KEYLET_* constant). |
api::ledger | Ledger-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::state | Typed single-value state helpers (state_u32, state_xfl, state_update_u64, …) alongside the composite layer below. |
api::sto | STObject parsing helpers. |
api::trace | The support functions backing trace!/trace_num!/trace_float!. |
api::util | util_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
sfcodesglob.sfield’s typedSField<T>constants take those same names, sosfSequencein the prelude is anSField<u32>, not a bareu32. The raw table is still available atrshooks::raw::sfcodes::*for const contexts whereIntocannot be called —txn_template!’s field tables, or aconstheader 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 registersSlotObjectmanages, and mixing the two silently corrupts handles. They stay public atrshooks::api::slot::*(plusrshooks::api::otxn::otxn_slot) — reaching for them explicitly at least makes the escape hatch visible at the call site.
Workarounds: rshooks::raw::sfcodes::* for raw sfield codes,
rshooks::api::slot::* for the numbered slot API. See The Raw Layer
and Slots and Ledger Objects.
The Raw Layer
rshooks::raw is a direct re-export of the rshooks-core crate — a plain
alias, not a re-exporting wrapper module, so every path under
rshooks::raw::* is identical to the same path under rshooks_core::*.
This is the lowest layer of the toolchain: no_std, zero-logic, 1:1
translations of the xahaud hook/ C headers into Rust, with no Result
type, no typed wrappers, and no ergonomics of any kind.
Everything else in rshooks — api, state, slot_obj, the macros — is
built on top of this layer. Most hook code never needs to reach into it
directly; see The Prelude for what the ergonomic surface
covers instead.
What’s in it
| module | contents |
|---|---|
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::host | HookHost: 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::sfcodes | Every sfXxx serialized-field code (325 fields), each a u32 packing (type << 16) + index, mirrored verbatim from sfcodes.h. |
raw::tts | Every ttXxx transaction-type code (ttPAYMENT, ttHOOK_SET, …), from tts.h. |
raw::consts | KEYLET_* and COMPARE_* constants from hookapi.h, plus tfCANONICAL, the atACCOUNT family, and the amAMOUNT family from macro.h. |
raw::ls_flags | Every 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_flags | Every 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::error | Every 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
u32sfield code. The typedsfieldconstants (crate::sfield::sfSequence, etc., what the prelude re-exports under the plainsfXxxnames) areSField<T>values, not bareu32s — fine for ordinary calls, but unusable where a raw integer is required at compile time.txn_template!’s field tables are the main example:sfcodevalues there come fromrshooks::raw::sfcodes::*(orSField::code()) so they can participate inconst fnoffset arithmetic. See Emitting Transactions and Macro Reference. - An API the wrapper doesn’t cover.
rshooks::apiwraps the common Hook API surface withResult-returning, panic-free functions, but if a specific raw host call has no typed wrapper yet,rshooks::raw’sunsafe 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).
| # | example | demonstrates | book chapter |
|---|---|---|---|
| 01 | accept-all | minimal hook: accept everything (starter template) | Anatomy of a Hook |
| 02 | state-counter | state/state_set round-trip, counter in hook state | Hook State |
| 03 | hook-params | hook_param-configurable threshold, with a compiled-in default | Hook and Transaction Parameters |
| 04 | errors | a meaningful hook_errors!-based rollback error-code system, matched to HookReturnCode | Accept, Rollback, and Errors |
| 05 | firewall | read otxn_field(sfAccount) + a hook parameter blacklist → rollback | Reading the Originating Transaction |
| 06 | guard-patterns | guard!/guard_m! correctness, choosing maxiter, and the array-== memcmp-loop pitfall | Guards and Loops |
| 07 | xfl-math | reading Amount as XFL (slot_float/sto_set), mulratio, checked Add/Sub/Mul/Div/Neg operators, .compare()-family methods, and XFLUnchecked’s hot-path chain | XFL: Decimal Floating Point |
| 08 | slot-ledger | the typed slot layer: SlotObject::from_otxn() → .get(sfXxx) → .value(), with no slot numbers in sight, measured against the raw numbered API it replaced | Slots and Ledger Objects |
| 09 | state-foreign | state_foreign: reading another (hook-parameter-configured) account’s hook state | Hook State |
| 10 | emit-txn | etxn_reserve + a txn_template!-declared Payment/emit, with a cbak | Emitting Transactions |
| 12 | typed-data | #[derive(HookData)]: composite (multi-field) state keys/values and otxn_param/hook_param structs, in place of hand-packed byte buffers | Typed Data with Derives |
| 13 | keylets | rshooks::api::keylet’s 26 typed keylet_xxx helpers (one per KEYLET_* constant), in place of the single untyped util_keylet | Keylets |
| 14 | account-id-macro | rshooks::account_id!: compile-time r-address → AccountId decode, cross-checked against hook_account/util_accid/util_raddr | Reading the Originating Transaction |
| 15 | slot-objects | the 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! failures | Slots 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 01–15 (one concept each, in suggested reading order), the 80+
series are behavior-equivalent Rust ports of real, deployed xahaud C hooks —
read them after 01–15, 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.
| # | example | ports |
|---|---|---|
| 80 | reward | hook/genesis/reward.c — the RewardHook: computes and emits a GenesisMint crediting ClaimReward claimants and active-validator L1 seats |
| 81 | govern | hook/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.