Introduction
rshooks is a Rust toolchain for writing Xahau
Hooks — small WebAssembly programs that attach to an account and run
alongside transactions that touch it. It covers the whole path from source
to a deployable binary: an ergonomic Rust API for the Hook host functions,
procedural macros that remove the WASM export boilerplate, and a build CLI
that turns a cargo build artifact into a SetHook-valid .wasm file.
What is a Xahau Hook?
A Hook is a tiny WebAssembly module installed on an account via a SetHook
transaction. Once installed, it runs synchronously whenever a transaction
matching its trigger set (HookOn) touches that account — before the
transaction is finally applied, for incoming or outgoing transactions or
both. The Hook inspects the transaction and the ledger through a fixed set
of host-provided API functions, then either accepts (lets the transaction
proceed) or rolls back (rejects it), optionally emitting new transactions
of its own along the way.
Because a Hook runs as part of consensus, the host imposes strict rules a
compiled binary must satisfy: it may export only hook (and optionally a
cbak settlement callback), every loop must carry an explicit guard so the
host can statically bound its worst-case execution, the instruction set is
plain WebAssembly 1.0 with no floating-point opcodes at all, the call graph
must be acyclic, and the whole module must fit in 65,535 bytes. These
constraints are why Hooks aren’t written like ordinary Rust programs —
rshooks exists to meet them without making the developer hand-encode WASM
exports or floating-point-free arithmetic by hand.
The five crates
rshooks is a small monorepo, layered so each crate has one job:
| 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 (the #[hooks] struct/impl attribute, 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 one or more SetHook-valid WASM binaries: a discovery build plus one build per declared Hook, each post-processed by a hook-cleaner and guard-checker, natively in Rust. |
rshooks-testenv | An off-chain unit-test harness with a mock Hook host, for testing Hook logic without WASM or a running Xahau node. |
This book focuses on the ergonomic layer — the rshooks crate and its
macros — since that’s what Hook authors write against day to day. The raw
rshooks-core FFI bindings are covered briefly in the reference
chapter for when you need to drop down to the bare host
call, but every worked example in this book uses rshooks’s typed,
Result-returning wrappers.
How this book is organized
- Getting Started walks through installing the toolchain and building
your first Hook, the minimal
accept-allexample, end to end. - Core Concepts covers the shape every Hook shares: the
#[hooks]struct/impl declaration, entry points, accept/rollback and the error model, the loop-guard system, tracing, and the multi-Hook chain model. - Working with Data covers reading the originating transaction, Hook
state, parameters, typed derives, the
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 the per-hook attributes it reads to generate a SetHook template. - Reference is a lookup appendix: the full macro list, the prelude’s contents, the raw FFI layer, and an index of the runnable examples in the repository.
Every code sample in this book is adapted from a real, runnable example in
the rshooks repository’s examples/ directory — see the Examples
Index for the complete, numbered list.
Installation
Building a Hook needs two things beyond a normal Rust setup: the
wasm32v1-none compilation target, and the rshooks CLI that
post-processes cargo’s output into a SetHook-valid binary. This page sets
up both, plus the shape of a new Hook crate’s Cargo.toml.
Rust toolchain
rshooks targets a stable Rust toolchain, edition 2024. wasm32v1-none has
been stable since Rust 1.84; the rshooks repository itself pins a specific
version via rust-toolchain.toml:
[toolchain]
channel = "1.89.0"
targets = ["wasm32v1-none"]
components = ["rustfmt", "clippy"]
If you’re not using rustup’s toolchain-file auto-detection, add the
target explicitly:
rustup target add wasm32v1-none
Installing the build CLI
cargo install rshooks-build
This installs a binary named rshooks (from the rshooks-build package)
used throughout this book. It wraps cargo build --target wasm32v1-none
and does not replace your regular cargo — you still need a working Rust
install on PATH.
Adding rshooks to a new crate
cargo add rshooks
or add it to Cargo.toml directly. A minimal Hook crate looks like this:
[package]
name = "my-hook"
version = "0.2.3"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# no_std cdylibs have no `test` crate for wasm32v1-none; disable the
# (impossible) unit-test harness target.
test = false
[dependencies]
rshooks = "0.2.3"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
A few things worth noting about this shape:
crate-type = ["cdylib"]— a Hook compiles to a C-compatible dynamic library; that’s the 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.2.3", features = ["host-panic-handler"] }
Never enable this feature from a std context — it’s meant only to make
host-side tooling work for an otherwise no_std Hook crate. It has no
effect on the actual wasm32v1-none build.
With the toolchain, the CLI, and a crate shaped like this in place, you’re ready to write your first Hook.
Your First Hook
This chapter walks through accept-all, the minimal starter Hook: it
traces a short message, then unconditionally accepts the transaction that
triggered it. No loops, no state, no emitted transactions — a good template
to copy for a new Hook and a good way to see every required piece in one
small file.
Before the code, two concepts this whole book builds on:
- The struct is a declaration vessel, not a runtime instance. A
#[hooks]struct never gets constructed and never holds real data at runtime — it exists so a name, its fields, and the Hook entries that use them can all be declared in one place. Every entry receives this chain declaration by shared reference (&self), evenAcceptAll, which declares no fields — later chapters, once a chain has declared fields worth reading, show how an entry reaches them asself.<field>. - Every Hook entry has an explicit index. The index is this crate’s
Hook’s position in the account’s
Hooksarray (the one aSetHooktransaction installs into) — position0,1, and so on, up to9. Even a crate with exactly one Hook must say which position it occupies; there’s no implicit “the only one.”
The source
Create src/lib.rs in the crate you set up in Installation:
#![no_std]
use rshooks::*;
#[hooks(description = "Accepts every transaction selected by HookOn.")]
pub struct AcceptAll;
#[hooks]
impl AcceptAll {
/// Accepts every triggering transaction.
#[hook(0, name = "accept", on = [Invoke])]
fn main(&self) -> exit::HookResult {
trace!(b"accept-all: accepting transaction");
accept!()
}
}
This first hook exits through accept!() — the simplest thing that works
before Ok/Accept are introduced. The idiomatic typed exit
(Ok(Accept::from_code(0)) and ?-propagated errors) is covered in
Accept, Rollback, and Errors;
both compile to the same wasm here.
To see the trace! line actually run, enable the trace feature in
Cargo.toml alongside rshooks:
[dependencies]
rshooks = { version = "0.2.3", features = ["trace", "host-panic-handler"] }
#![no_std]
Every Hook crate is #![no_std]: there’s no allocator and no std on the
Hook host, and rshooks itself is no_std so it can be linked into one.
use rshooks::*;
This glob import brings in everything declared at rshooks’s crate root:
the #[hooks] attribute macro, the XFL!/account_id! macros, the
accept!/rollback!/trace!/guard! macro family, and every top-level
module (api, types, xfl, …) by name. It does not bring the
functions inside those modules into scope — a Hook that calls typed API
functions like otxn_field or state also needs use rshooks::prelude::*;,
which this minimal example doesn’t, since it never reads the transaction or
touches state. Later chapters that do add that import.
#[hooks(description = "...")] on the struct
#[hooks] on struct AcceptAll; declares this crate’s Hook chain: a
container for shared state/parameter fields (none here — AcceptAll is a
unit struct, so
there’s nothing to declare) plus the optional description, free-form text
carried into the build’s generated sidecar. The struct name (AcceptAll)
is yours to choose; it plays no on-ledger role.
#[hooks] on the impl block, and #[hook(0, ...)]
The second #[hooks] attribute, on impl AcceptAll, marks this as the
chain’s entry-point block — exactly one such impl is required per
#[hooks] struct. Inside it, #[hook(0, name = "accept", on = [Invoke])]
declares one Hook entry:
0— the required, positional first argument: this Hook occupies position0in theHooksarray. Explained further in Hook Chains.name = "accept"— the on-ledgerHookNamethis Hook installs with (optional; omit it for an unnamed Hook).on = [Invoke]— this Hook fires only forInvoketransactions. Omittingonentirely is also legal — see Per-Hook Attributes for what that means.
The annotated function itself is an associated function taking &self and
returning exit::HookResult — reached here as exit::HookResult because
use rshooks::*; already brought the exit module in by name (see
above). AcceptAll declares no fields, so main never reaches through
self here, but the receiver is still required — #[hooks] expands it
into the wasm export the Hook host requires:
#[unsafe(no_mangle)]
pub extern "C" fn hook(_reserved: u32) -> i64 {
::rshooks::exit::EntryReturn::finish(AcceptAll::main(&AcceptAll))
}
The function’s own name (main here) is just a convention; what matters is
the hook export it produces for this entry’s build. &self is the one
receiver form #[hooks] accepts on an entry — no lifetime, not mut — and
it’s how an entry reaches a chain’s declared fields once there are any to
reach, covered in Hook State. Any other receiver shape
(self, mut self, &mut self, self: T), a missing receiver, or a
return type that doesn’t implement the sealed EntryReturn trait (in
practice, anything but rshooks::exit::HookResult) is rejected at compile
time with a pointed error rather than a malformed export. Use #[cbak(0)]
the same way, on the same index, to declare the optional settlement
callback for this entry.
accept!()
accept!() calls the host’s accept function and never returns — its
return type is !. accept!(msg, code) additionally carries a trace
message and a caller-chosen result code; the bare form used here accepts
with no message and code 0. Its counterpart, rollback!(msg, code),
rejects the transaction instead. Both are covered in more depth in Accept,
Rollback, and Errors.
Building it
From the crate’s own directory:
rshooks build --out out
or from elsewhere, pointing at its manifest:
rshooks build --manifest-path my-hook/Cargo.toml --out my-hook/out
--out out picks where the build publishes its output; omit it and
rshooks build publishes under <target>/rshooks/<crate-name> instead
(see “What lands in out/” below). This compiles your crate once to
discover its declared Hook(s), then once more per declared index, and
post-processes each result — see Building a Hook for
exactly what that pipeline does. A successful build prints something like:
discovery build (my-hook)
building entry 0 (`main`)
wrote out/current/0.main.wasm (171 bytes, estimated SetHook fee 855000 drops)
wrote out/current/0.main.metadata.json
wrote out/current/sethook.template.json
wrote out/current/sethook.template.meta.json
The guard checker’s/validator’s own numbers (worst-case instructions, max
nesting depth) aren’t printed here — they land in the metadata sidecar
below instead, or print on the terminal via rshooks check out/current/0.main.wasm;
see Building a Hook for that
report’s exact shape.
What lands in out/
rshooks build writes into a generation directory under its output root
— out/ above, since --out out was passed; the default, if --out is
omitted, is <target>/rshooks/<crate-name>, where <target> is cargo’s
own target directory — with <root>/current symlinked to the latest
generation (see Hook Chains for why generations
exist). For AcceptAll, out/current/ contains:
0.main.wasm— the cleaned, SetHook-valid binary for index0: cargo’s rawcdyliboutput with thememoryexport stripped and every Hook API rule (§ singlehook/cbakexport, guarded loops, MVP-only instructions) validated. The file name is<index>.<fn>.wasm— one file per declared entry, so a multi-Hook chain gets one independent binary per index.0.main.metadata.json— this entry’s metadata sidecar:
{
"index": 0,
"hook_fn": "main",
"cbak_fn": null,
"name": "main",
"description": null,
"HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
"HookCanEmit": null,
"HookName": "616363657074",
"HookHash": "68A7D6BD77FB98A7E05E0BFD12180352903F131B2780F848267C37DC0837707C",
"WCE": {
"hook": 14,
"cbak": 0
},
"builder": {
"name": "rshooks-build",
"version": "0.2.3",
"rustc": "rustc 1.89.0 (29483883e 2025-08-04)",
"cargo_args": ["rustc", "--release", "--locked", "--target", "wasm32v1-none", "--crate-type", "cdylib"],
"rustc_args": ["--cfg", "rshooks_entry=\"0\"", "--check-cfg", "cfg(rshooks_entry,values(\"0\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\"))", "-C", "link-arg=-zstack-size=131072"],
"wasm_opt": true
},
"human": {
"on": {
"form": "list",
"HookOn": ["Invoke"],
"HookOnIncoming": null,
"HookOnOutgoing": null
},
"HookCanEmit": null,
"HookName": "accept"
},
"chain": {
"struct": "AcceptAll",
"description": "Accepts every transaction selected by HookOn.",
"decls": { "state": [], "hook_params": [], "otxn_params": [] }
}
}
builder records the toolchain provenance for this build. A crate built
with the unstable-param-sig-interface feature additionally carries a
sig_params field (the entry’s declared typed signature parameters);
on a stable build like this one the key is absent entirely. The full
grammar for every field here is covered in
Per-Hook Attributes.
sethook.template.json/sethook.template.meta.json— a ready-to-editSetHooktransaction template covering every index this crate declares, plus a sidecar recording how it was generated. Covered in full in Hook Chains and Per-Hook Attributes.
The WCE (worst-case execution) numbers are the static, guard-derived
upper bound on instructions the host will ever execute for this entry’s
hook/cbak — the same figures the pipeline printed to the terminal. The
HookHash is Xahau’s hash of the deployed binary: the uppercase hex of
the first 32 bytes of the wasm’s SHA-512 digest — this is what identifies
the exact Hook code on-ledger, independent of which account installed it.
The chain object transcribes this crate’s shared struct-level schema
(empty here, since AcceptAll declares no fields) — every entry’s sidecar
carries the same chain object, since the schema is shared across the
whole crate, not owned by any one entry.
From here, Building a Hook explains what each pipeline
stage actually does, Hook Chains covers the
multi-Hook model this build pipeline exists for, and The rshooks
CLI is the complete flag reference for every subcommand.
Building a Hook
The previous chapter ran rshooks build without explaining what it
actually does. This chapter walks through the pipeline stage by stage, so
the printed report and the check subcommand make sense on their own. A
crate can declare more than one Hook (see Hook
Chains); this chapter describes the pipeline for
one declared entry, and the next section covers how it repeats.
The pipeline
rshooks build runs cargo, then a fixed sequence of post-processing
and validation steps:
- Discovery build —
cargo build --release --target wasm32v1-noneonce, with no entry selected, compiling every declared Hook andcbakinto one artifact. This build is never deployed; its only purpose is to read back the crate’s declarations (the#[hooks]struct’s shared schema and every#[hook]/#[cbak]entry’s metadata), extracted from dead, hex-encoded carrier exports the macros generate and this artifact alone carries. - Per-index build, once per declared entry — for each index the crate
declares,
cargo rustc --release --target wasm32v1-none -- --cfg 'rshooks_entry="<i>"'recompiles the same crate with that one entry selected. The--cfgflag steers the same#[hooks]-generated code to export exactlyhook(andcbak, if this index declares one) instead of the discovery build’s suffixed names — this is the only artifact that’s ever SetHook-valid for that index. The tool then re-extracts this build’s own carriers and checks them byte-for-byte against discovery’s — a mismatch (a build script orcfg-sensitive macro producing different declarations at a different--cfgvalue) is a build error naming exactly which entry and field diverged. - Hook-cleaner (per index) — strips the disallowed
memoryexport, the now-redundant carrier exports, and any other dead export, and flattens and inlines the crate’s call graph into thehook/cbakentry points for this index only, untangling the resulting block/loop/if nesting so it fits the host’s structural limits. Because each index is compiled and cleaned separately, one index’s unreachable code (another entry’s logic, in a multi-Hook chain) never counts against this index’s own size or nesting budget. - Guard checker (per index) — validates that every loop begins with
the exact guard call sequence the host requires, and computes the
static worst-case instruction count (WCE) for
hookand, if present,cbak, from those guards. - Validator (per index) — checks the complete SetHook rule set:
exactly one
hookexport (and at most onecbak, and only if this index declared one), no disallowed imports, no recursion, and a binary size at or under the 65,535-byte SetHook limit (unless--allow-oversizeis passed, in which case the output is still written but clearly marked invalid). - Sidecar and template generation — once every index has been built
and validated, writes one
<index>.<fn>.metadata.jsonsidecar per entry, then asethook.template.jsoncovering every declared index in oneHooksarray, plus itssethook.template.meta.jsongeneration sidecar. Covered in Hook Chains and Per-Hook Attributes. - Publish — stages every artifact from this run, then atomically
updates the output root’s
currententry (out/currentbelow — wherever--outpoints, or<target>/rshooks/<crate-name>by default; see Your First Hook) to point at it. A failed run never touchescurrent; it always resolves to the most recent complete, validated build.
Every wasm-producing step runs against the exact bytes that will be
deployed — the WCE and HookHash recorded in that entry’s metadata
sidecar describe the file actually written to out/current/, not an
intermediate artifact.
Reading the printed report
build prints its progress as it goes: a discovery line, one
building entry <index> (...) line per declared entry, then a wrote ...
line for every published artifact once the whole chain has built
successfully:
discovery build (accept-all)
building entry 0 (`main`)
wrote out/current/0.main.wasm (171 bytes, estimated SetHook fee 855000 drops)
wrote out/current/0.main.metadata.json
wrote out/current/sethook.template.json
wrote out/current/sethook.template.meta.json
build itself doesn’t print the guard checker’s or validator’s numbers —
those land in that entry’s <index>.<fn>.metadata.json sidecar (the WCE
object) instead. To see them on the terminal, run rshooks check against
the binary build just wrote:
$ rshooks check out/current/0.main.wasm
worst-case instructions: hook=14 cbak=0
max nesting depth: 0
OK: out/current/0.main.wasm is a valid SetHook wasm binary
size: 171 bytes
estimated SetHook fee: 855000 drops (0.855000 XAH)
worst-case instructionsis the guard checker’s static upper bound on instructions the host will ever execute for each entry point.max nesting depthis the deepest block/loop/if nesting in the final module, checked against the host’s structural limit — 32 for a Guard-type module. This is the number Hook Chains covers in more depth: dense use of the typed#[state]/#[hook_param]/#[otxn_param]accessors at one call site can push it close to that ceiling.size/estimated SetHook feeare computed directly from that entry’s final binary 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. Because each index is its own independent wasm, each has its own size and its own fee — a multi-Hook crate’s total deployment cost is the sum across every declared index.
Validating a binary without building it
rshooks check <file> runs the same guard-checker and validator
steps against an existing wasm file, without invoking cargo or writing any
output. It works on any SetHook-shaped wasm, including one this toolchain
didn’t build — see The rshooks CLI for its full
flag reference, and build’s and clean’s
as well.
A note on compiler-generated loops
Guards are your responsibility: an unguarded loop is treated as a hard
build error, on the principle that a missing guard! in your own source
is a bug, not something the toolchain should paper over. This includes
loops the compiler generates that never appear in your Rust source at
all (certain array-equality and buffer-zeroing patterns can lower to an
unguarded loop at the WASM level). The source-level idioms that avoid
those loops entirely are covered in the
Guards and Loops chapter.
Anatomy of a Hook
A Xahau Hook is a small WebAssembly module with one required export and one
optional export. This page walks through what an rshooks hook crate looks
like from top to bottom: the crate shape, the #[hooks] struct/impl
declaration, how a hook’s execution model shapes the way you write code,
and the statics idiom used for templates and large buffers. Understanding
this shape first makes the rest of the book — data access, errors, guards —
much easier to place. This page covers a single-entry crate; Hook
Chains extends the same shape to a crate declaring more than
one Hook.
The crate shape
Every hook crate is a no_std cdylib:
#![no_std]
use rshooks::prelude::*;
use rshooks::*;
#[hooks(description = "Accepts every transaction selected by HookOn.")]
pub struct AcceptAll;
#[hooks]
impl AcceptAll {
#[hook(0, name = "accept", on = [Invoke])]
fn main(&self) -> HookResult {
trace!(b"accept-all: accepting transaction");
Ok(Accept::from_code(0))
}
}
(adapted from examples/01_accept-all/src/lib.rs.) A few things to notice:
#![no_std]— there is no heap, no OS, nostd::anything.rshooks’spreludemodule gives you the ergonomic surface (typed accessors, macros, common types) without depending onstd.- The struct (
AcceptAll) is this crate’s chain-declaration vessel — a place to name shared state/parameter fields (none here) and carry a build-onlydescription. It’s never constructed and holds no runtime data; see “The struct has no runtime instance” below. - The impl block, also annotated
#[hooks], is where the actual entry functions live, each marked with#[hook(<index>, ...)]or#[cbak(<index>)].name/on/can_emit/descriptionare per-entry attributes now, rather than a separate top-level declaration — covered in full in Per-Hook Attributes.
#[hooks]: struct and impl, always as a pair
Every chain needs exactly one #[hooks] struct and exactly one
#[hooks] impl block for it, in the same module — the two halves are
linked by name (impl AcceptAll refers back to struct AcceptAll), and
the macros generate a compile-time handshake between them, so an impl
with no matching annotated struct (or vice versa) fails to compile with a
dedicated error rather than silently doing nothing.
The struct itself can be a plain unit
struct (struct AcceptAll;, as above, when there’s no state or parameters to declare) or a
named-field struct whose fields carry #[state]/#[hook_param]/
#[otxn_param] attributes — covered in Hook State and
Hook and Transaction Parameters. Moving from one
to the other is exactly “replace the trailing ; with a field block”;
nothing else about the declaration changes.
The struct has no runtime instance — but every entry borrows it
This is worth stating plainly, because Rust’s struct/impl syntax normally
implies an object with methods that take self. Here, you never
construct one. For a struct with declared fields, the macro generates its
own single, zero-sized instance for you — a static named the same as the
struct (static Vault: Vault) — existing purely so its fields’ declared
state/parameters have something to hang accessor methods off. AcceptAll
is a unit struct (struct AcceptAll;) with no fields to hang anything off,
so no static is generated for it at all; AcceptAll the identifier
already names its own unit value (Rust gives every unit struct exactly one,
for free), and that’s the value &self borrows in its entry above.
Every entry takes &self to receive that value by shared reference, and
reads any declared field through its kind’s namespace —
self.state.some_field, self.hook_param.some_field, or
self.otxn_param.some_field — the canonical style whenever an entry, or a
helper function inside the same impl, touches a declared field:
#[hooks]
impl StateCounter {
#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);
// ...
}
}
Code outside the impl — a free function, another module — has no self
to borrow, so it reaches the identical static by the struct’s own name
instead: StateCounter.state.counter.get(). Both spellings name the same
zero-sized value; &self is a reference to a zero-sized value, so it
optimizes away completely, even across an #[inline(never)] boundary. Use
&self inside the annotated impl and the struct-name static everywhere
else.
The one receiver an entry accepts is bare &self — no lifetime, not
mut, and not optional. A missing receiver, or any other self-receiver
shape (self, mut self, &mut self, self: T), is a compile error
with a dedicated diagnostic rather than a type mismatch: chain handles are
zero-sized and immutable, so there is nothing to own or write through —
only to read through a shared reference. A non-attributed helper function
declared inside the same impl is less strict: it accepts either no
receiver or &self, whichever its own body needs.
Entry functions: #[hook(<index>, ...)] and #[cbak(<index>)]
The Hook host requires a wasm export shaped like
extern "C" fn hook(_reserved: u32) -> i64. Writing that by hand means an
unsafe extern "C" function signature in every hook crate. #[hooks]
avoids that: it takes a plain associated function and generates the export
for you, per selected build (see Building a Hook
for what “per selected build” means).
#[hooks]
impl AcceptAll {
#[hook(0)]
fn main(&self) -> HookResult {
Ok(Accept::from_code(0))
}
}
expands, for index 0’s own build, to the original function unchanged,
plus:
#[unsafe(export_name = "hook")]
pub extern "C" fn __rshooks_hook_sel_0(_reserved: u32) -> i64 {
::rshooks::exit::EntryReturn::finish(AcceptAll::main(&AcceptAll))
}
The macro enforces the annotated item’s shape exactly. Receiver, modifier,
and generic violations are a compile_error! at the offending token rather
than a panic; a return type that does not implement the sealed
EntryReturn trait is an ordinary E0277 naming EntryReturn:
- a bare
&selfreceiver (see above), optionally followed by one further argument; - a return type implementing the sealed
EntryReturntrait (currently, onlyrshooks::exit::HookResult); - no
async/unsafe/const/externmodifiers; - no generics, no
whereclause.
The annotated function’s own name is arbitrary — main is just a
convention carried through every example in this book. What matters is the
hook export it produces for its declared index.
#[cbak(<index>)] is the counterpart for the same index: a Hook entry can
optionally have one, generating a cbak export instead of hook. The host
invokes it when a transaction the hook previously emitted (via emit)
later settles on ledger, so the hook can react to its own emission’s
outcome. Its fn may declare one argument after &self — EmitOutcome (or
a raw u32) — decoded from the host’s own cbak(u32) argument; #[hook]
instead accepts signature-parameter arguments there (see Hook and
Transaction Parameters).
See Emitting Transactions for a worked #[cbak]
example. Both attributes take one required argument — the index —
plus, for #[hook], the optional named metadata arguments covered in
Per-Hook Attributes.
Execution model
Each Hook invocation runs in a freshly instantiated wasm instance: there
is no persistent process, no threads, and no state carried in memory from
one invocation to the next (anything that needs to persist belongs in
Hook State, not a Rust static’s runtime value). A hook
must always terminate by calling into the host’s accept or rollback —
covered in Accept, Rollback, and Errors — there is no implicit
“fall off the end and succeed.”
This single-threaded, single-shot model is also what makes the statics
idiom below sound: nothing else can be running concurrently, or left over
from a previous call, that could alias a static’s contents.
Source style: what a hook avoids
Because a hook has a small, fixed instruction budget enforced by the guard
system (see Guards and Loops) and no defined behavior for an
unhandled panic on the Hook host, rshooks hooks are written to avoid
panicking operations entirely, not merely to survive them:
- No slice indexing or range-slicing with a non-literal index (use
.get()/.get_mut(), which 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
- Hook Chains extends this shape to a crate declaring more than one Hook — a shared struct, several indexed entries, and what changes about the build.
- 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.
Hook Chains
Anatomy of a Hook covered a #[hooks] struct/impl pair
declaring a single Hook. Nothing about that shape is actually limited to
one entry: the same struct can carry more than one #[hook(<index>, ...)]
(and matching #[cbak(<index>)]) in its impl block, each with its own
index. This page covers what changes when it does — how index maps to
on-ledger position, why a shared struct is the model’s biggest win, what a
multi-Hook build actually produces, the SetHook template’s exact
semantics, and a real, measured limit worth knowing about before you lean
on this model too hard in one entry.
One struct, one chain
A single #[hooks] struct plus its one #[hooks] impl is this crate’s
entire chain declaration — not “a Hook,” but everything this crate
contributes to an account’s Hook chain, across however many indices it
declares. There is deliberately no way to have two chains in one crate:
each #[hooks] struct generates a fixed-name linker symbol, so a second
one collides and fails to link. One crate, one chain.
Index: chain position, not just an artifact ID
#[hook(<index>, ...)]’s leading integer means two things at once:
- Which artifact this entry becomes — its own wasm, its own metadata sidecar, built and validated independently of every other entry in the crate (see “What a chain build produces,” below).
- Where it sits in the account’s
Hooksarray — the same array aSetHooktransaction installs into. Index0isHooks[0], index3isHooks[3], and so on.
Valid indices are 0..=9 (a SetHook transaction’s Hooks array holds at
most 10 entries), and gaps are allowed: a crate can declare only 0
and 2, leaving position 1 for something else entirely (a different
crate’s Hook, installed and managed separately). What gaps mean for the
generated template is covered below.
#[cbak(<index>)] pairs with a #[hook] at the same index — it
doesn’t take its own name or trigger, just the index it settles for. An
index can have a hook with no cbak; an index with a cbak but no hook is a
compile error (there’d be nothing for it to settle), and a crate declaring
zero hooks at all is also a compile error — a chain with nothing in it
isn’t a useful chain.
Because index is written in the source, changing which position a Hook
occupies is a source change, not a deployment-time choice: reviewing a diff
to #[hook(1, ...)] is reviewing exactly what’s moving where in the
account’s chain. If you need to install the same compiled wasm at a
different position without touching source, the generated template’s
Hooks array (below) can still be reordered by hand before submission —
the wasm itself doesn’t encode its own position.
The shared schema: why this is the model’s biggest win
Every field on the #[hooks] struct — every #[state], #[hook_param],
#[otxn_param] — is declared exactly once and can be referenced from
any entry in the same impl block. This is the actual payoff of the
model: state or parameters two Hooks in the same chain both touch get one
Rust-level declaration, type-checked once, instead of one copy per crate
silently drifting out of sync.
examples/80_governance is the worked example — a Rust port of xahaud’s
genesis govern/reward pair, which on the real network are installed
side by side, Hooks[0] = govern and Hooks[1] = reward. The two entries
share a state layout (the reward rate/delay, and the seat table) that
neither one exclusively owns — declaring them as one chain gives that
shared layout a single Rust-level declaration, instead of leaving it to be
duplicated (and potentially drift) across two independent crates. As one
chain:
#[hooks(description = "20-seat L1/L2 governance and reward chain")]
pub struct Governance {
/// L1 reward rate. Written by governance; read by both governance
/// and reward.
#[state(key = b"RR")]
reward_rate: State<XFL>,
/// L1 reward delay (seconds). Same story as `reward_rate`.
#[state(key = b"RD")]
reward_delay: State<XFL>,
// ... member_count, seat_forward, member_reverse, and this chain's
// hook parameters, all declared once here.
}
#[hooks]
impl Governance {
#[hook(0, on = [Invoke], can_emit = [Invoke, SetHook])]
fn govern(&self) -> HookResult { /* ... reads and writes self.state.reward_rate/self.state.reward_delay */ }
#[hook(1, on = [Invoke, ClaimReward], can_emit = [GenesisMint])]
fn reward(&self) -> HookResult { /* ... reads self.state.reward_rate/self.state.reward_delay */ }
}
Both entries declare a &self receiver and reference
self.state.reward_rate/self.state.reward_delay directly — there is exactly one Rust
type for that state entry, so govern’s write and reward’s read can
never silently disagree about the key’s shape or the value’s layout. (The
real examples/80_governance crate’s dense govern/setup path writes
reward_rate/reward_delay through the raw API instead — see “A real
limit,” below, for why — while reward’s own two reads still go through
the typed self.state.reward_rate/self.state.reward_delay accessors shown here; this
sketch shows the model at its cleanest.)
One nuance worth being precise about: the struct shares the schema, not
the values. Both entries read/write the identical on-ledger state key —
that part genuinely is shared — but a Hook parameter declared on the
struct is installed independently per index. Governance’s config field
(if it had one) could be installed with one value at index 0 and a
different value at index 1; the struct only guarantees both entries agree
on the parameter’s shape, not that they were configured identically.
What a chain build produces
rshooks build compiles a multi-Hook crate once per declared index (see
Building a Hook for the discovery-plus-
per-index pipeline), producing, for Governance above (with --out out
passed, per Your First Hook):
out/current/
0.govern.wasm
0.govern.metadata.json
1.reward.wasm
1.reward.metadata.json
sethook.template.json
sethook.template.meta.json
Each <index>.<fn>.wasm is a complete, independent wasm module: only
the code reachable from that one entry’s own #[hook]/#[cbak] functions
gets compiled in — govern’s logic never appears in 1.reward.wasm, and
vice versa. The direct consequence is that the 65,535-byte SetHook size
limit, and the 32-level structural nesting limit the guard checker
enforces, apply per index, not to the crate as a whole. A chain of ten
entries effectively has ten times the budget of one entry, split across ten
independent artifacts, rather than one shared pool.
The output root itself is generation-numbered (<root>/gen-<N>/, with
<root>/current a symlink to the latest complete, validated one — out/
above, or <target>/rshooks/<crate-name> if --out is omitted) so a
build in progress, or one that fails partway through, never leaves
current pointing at a half-written result.
The SetHook template: an owned-position patch, not a full chain
sethook.template.json is a ready-to-edit SetHook transaction covering
every index this crate declares — but it is deliberately not a
declarative statement of what the whole account’s chain should look like.
It’s a patch over the positions this crate owns:
{
"TransactionType": "SetHook",
"Account": "<ACCOUNT>",
"Hooks": [
{ "Hook": { "CreateCode": "<hex of 0.govern.wasm>", "...": "..." } },
{ "Hook": { "CreateCode": "<hex of 1.reward.wasm>", "...": "..." } }
]
}
The Hooks array is exactly as long as the highest declared index plus
one — 0..=max, no padding beyond it. Every gap (a position with no
declared entry, but below the highest one) becomes an empty {"Hook": {}}
object: SetHook’s own no-op spelling for “leave whatever’s at this position
alone.” That’s a deliberate, load-bearing distinction — {"Hook": {}}
means “don’t touch this slot,” not “this slot is empty.” Two things follow
directly from that:
- Submitting this template never removes or overwrites a Hook at a gap position, or at any position past the array’s end, even if one is already installed there.
- The template therefore does not guarantee the account’s chain matches this crate’s source after submission — only that this crate’s own declared positions end up matching. If something else is installed at a gap or beyond, it’s still there afterward; reconciling the whole account’s chain against source is outside what a generated template does.
The generated template is also fail-closed by default: it carries no
Flags field at all, so submitting it as-is only succeeds against
currently-empty declared positions — it will not silently overwrite an
existing Hook. Pass --override at build time to add hsfOVERRIDE to
every declared (non-gap) position, permitting replacement; gap objects
never receive Flags, since adding one would turn a no-op into a real
operation.
Account and HookNamespace are left as placeholders ("<ACCOUNT>",
"<NAMESPACE>") unless you pass --account/--namespace at build time —
there’s no way for the build to know which account this template is meant
for. sethook.template.meta.json, alongside it, is generation provenance
(not itself part of the transaction): hook hashes, the declared/gap
position lists, and the amendment set the declared fields require. Both
files’ exact shape — and the full per-entry attribute grammar that drives
them (name, on, can_emit, and so on) — are covered in Per-Hook
Attributes.
A real limit: typed-accessor density inside one entry
This is a genuine, measured constraint, not a style preference, and it’s the main reason to actually read this section rather than skim it.
Every layer the typed #[state(..)]/#[hook_param(..)]/#[otxn_param(..)]
accessors go through — .at(..) → .get() → the underlying host call →
decode — is zero-cost at the Rust level (every layer is
#[inline(always)]). But the Guard-type build pipeline’s cleaner stage
force-inlines everything reachable into one hook() body, regardless of
Rust-level inlining hints, and then has to fit the result under the host’s
32-level structural nesting limit. A single entry with many sequential
typed-accessor call sites in the same function accumulates nesting from
each one — #[inline(never)] on a helper doesn’t exempt its own call sites
from this, since the constraint is call-site density within whatever
function ends up holding them, not which function that happens to be.
examples/80_governance hit this directly: govern’s setup path, with
roughly fifteen typed-accessor call sites across a few helper functions,
compiled to a post-cleaning nesting depth of nearly twice the limit of
32. Reverting exactly those dense call sites to the underlying raw API
(same section below) brought it well under the limit; the committed depth
is in examples/80_governance/metrics.json.
Every other example in this book stays comfortably under budget — this
shows up specifically at governance’s call-site density, in one
Guard-type entry. It’s worth knowing about before you assume the typed
layer scales to an arbitrarily dense entry, not something to preemptively
work around in an ordinary hook.
The escape hatch: the raw API, same declared bytes
When one entry’s typed-accessor density pushes past budget, the fix isn’t to abandon the struct’s shared declaration — it’s to keep the declaration (so the schema is still centrally documented and type-checked) but read or write the same key/name bytes through the lower-level free functions at just the dense call sites:
// Governance.state.reward_rate's own declared key is b"RR" — this hits the
// identical ledger slot, just without going through the typed accessor.
if state_set(value, b"RR").is_err() {
GovernError::AssertionFailed.nope(b"Governance: Assertion failed.");
}
Because the raw call uses the field’s own declared literal, it addresses
exactly the same on-ledger entry the typed accessor would — this is a
call-site choice about which API shape to go through, not a second,
diverging declaration. See Hook State and Hook and
Transaction Parameters for the raw
state/state_set/hook_param/otxn_param layer this falls back to, and
examples/80_governance/metrics.json for the current measured numbers
behind this section.
Where to go next
- Per-Hook Attributes is the complete grammar for
name/on/on_incoming+on_outgoing/can_emit/description, plus the exact shape of the generated sidecar andSetHooktemplate JSON. - Hook State and Hook and Transaction
Parameters cover the
#[state]/#[hook_param]/#[otxn_param]field declarations this page assumes. - The
rshooksCLI covers--account/--namespace/--overrideand every other build flag in full.
Accept, Rollback, and Errors
A Hook always terminates by returning rshooks::exit::HookResult from its
#[hook(<index>, ...)]/#[cbak(<index>)] entry: Ok keeps the
originating transaction’s effects, Err discards them. This page covers
rshooks’s Result/HookError type for Hook API failures, how an entry
actually exits (HookResult, Accept, Rollback, and ? propagation),
the accept!/rollback! macros — the in-body escape hatch a typed entry
falls back to for a computed message or a raw, zero-indirection body — and
hook_errors!/exit_on_err!, the idiom this crate provides for giving a
hook its own meaningful, stable error-code system instead of one
undifferentiated rollback code everywhere.
HookError and Result
Every Hook API function returns an i64: a non-negative value is a success
payload (often a byte count, or a slot/field-pointer value), and a negative
value is one of the documented error codes from the Hook API.
rshooks::error::HookError wraps that negative code directly — it is a
#[repr(transparent)] newtype over the raw i64, not a decoding enum, so
building one and reading the code back out are both the identity:
use rshooks::error::HookError;
let err = HookError::from(-5);
assert_eq!(err, HookError::DoesntExist);
assert_eq!(err.code(), -5);
Every wrapper in rshooks::api::* and rshooks::xfl returns
rshooks::error::Result<T> — a plain type alias for
core::result::Result<T, HookError> — so a failed host call surfaces as an
ordinary Err(HookError::SomeConstant) you can compare against, rather than
a raw negative integer. Each named Hook API error has an associated
constant with that name (HookError::DoesntExist, HookError::TooBig, …).
HookError is not an enum, so those constants are not patterns; matching a
specific error is a guard (Err(e) if e == HookError::DoesntExist => ..),
and HookError::kind() returns a HookErrorKind — an ordinary enum, with
HookErrorKind::Unknown for any code without a named constant — for
exhaustive dispatch.
It’s worth being precise about what HookError represents: it’s about why
a host call failed (out of bounds, doesn’t exist, invalid argument, and so
on) — not about why your hook rejected the transaction. That second
concept is what the rest of this page covers.
Typed entry returns: HookResult
Every #[hook]/#[cbak] entry returns rshooks::exit::HookResult — a
Result<Accept, Rollback> alias (rshooks::prelude re-exports
Accept/Rollback/HookResult; the module path is rshooks::exit).
Ok(Accept::new(msg, code)) accepts; Err(Rollback::new(msg, code)) rolls
back; ordinary ? propagates a failure out of a helper function, in place
of a hand-written accept!/rollback! call at every failure point:
use rshooks::exit::{Accept, HookResult};
hook_errors! {
pub enum DepositError {
BadAmount = 1 => b"deposit: bad amount",
StateSetFailed = 2 => b"deposit: state_set failed",
}
}
#[inline(always)]
fn read_amount(t: &Vault) -> Result<u64, DepositError> {
let bytes = t.amount.get_required().map_err(|_| DepositError::BadAmount)?;
Ok(u64::from_be_bytes(bytes))
}
#[hook(0, on = [Invoke])]
fn deposit(&self) -> HookResult {
let amount = read_amount(self)?;
// ... more `?`-propagated steps ...
Ok(Accept::new(b"deposit: ok", amount as i64))
}
Internally, the generated wrapper calls a sealed
::rshooks::exit::EntryReturn::finish(..) on whatever the entry returns: a
two-arm match calling accept/rollback on the host. HookResult is
the only type implementing that sealed trait, so it’s the only return shape
#[hooks] accepts on an entry or callback — returning anything else is a
compile error naming EntryReturn, not a bespoke macro diagnostic.
examples/16_typed-results is the full worked example.
Accept, Rollback, and the hook_errors! message clause
Accept::new(msg, code)/Rollback::new(msg, code) mirror accept!/
rollback!’s own arguments; Accept::from_code(code)/
Rollback::from_code(code) are the empty-message shorthand, and
.msg()/.code() read either type’s fields back. ? converts into
Rollback from any hook_errors! enum — every enum gets
impl From<Enum> for Rollback unconditionally, and hook_errors!
accepts an optional per-variant message clause that feeds it:
use rshooks::hook_errors;
hook_errors! {
pub enum DepositError {
/// Message clause: this variant's `Rollback` carries it.
BadAmount = 1 => b"deposit: bad amount",
/// No clause: this variant's `Rollback` gets an empty message.
StateSetFailed = 2,
}
}
The clause is per-variant — some variants may carry one and others not,
in the same enum — and an enum with no clause anywhere still gets the
From<Enum> for Rollback impl, with every message empty, so ? works
uniformly whether or not any variant bothers with a message. A raw code
is constructed with Err(Rollback::from_code(code)) (or
Rollback::new), not Err(code_i64)?.
The #[inline(always)] helper convention
Every helper function called on a ? path inside a typed entry should be
#[inline(always)], as read_amount is above. Measured
(.claude/design/TYPED_ENTRY_RESULTS_DESIGN.md §5’s p2fix probe): the
same logic through a plain (not force-inlined) Result-returning helper
costs a handful of extra worst-case instructions — an un-inlined
call-boundary cost, not a ?/Result cost — while force-inlined, the
identical typed code measured below its hand-written accept!/
rollback! twin. examples/16_typed-results follows this convention
throughout.
The one hard rule: never ? a raw HookError into Rollback
There is no From<HookError> for Rollback impl, and none is planned. A
Hook API error code (-1..=-45, -10024) is not the hook’s own
HookReturnCode — the two are different code spaces that happen to share
the i64 representation — so an implicit ?-propagated conversion from a
raw HookError into Rollback would publish the host’s code as the
hook’s own verdict, which is never the right default.
The supported pattern for a fallible Hook API call inside a typed entry is
.map_err(..), discarding the HookError and keeping only “some call
failed”:
let value = some_hook_api_call().map_err(|_| MyError::SomeCallFailed)?;
— exactly what read_amount does above. Fall back to accept!/
rollback! directly (see below) when a computed, non-'static message is
needed, or when dispatching on the specific error via HookError::kind()
is genuinely required.
accept! and rollback!: the in-body escape hatch
accept!/rollback! are macros that end execution immediately, calling
straight into the host’s accept/rollback — usable inside a typed
entry’s body, not a competing return shape for the entry itself. Reach for
them when:
- A message needs to be computed at runtime.
Accept::new/Rollback::newtake a&'static [u8], so a formatted or otherwise non-'staticmessage has nowhere else to go. - An entry’s body is written in a raw, zero-indirection style, with no
Result/?plumbing at all —examples/80_governance’s densegovern/rewardentries are exactly this case (see that example’s ownREADME.mdfor the measured reasoning).
Both macros accept the same two grammars:
accept!(); // no message, code 0
accept!(msg, code); // message bytes + application-defined code
rollback!(msg, code); // rollback always takes a message and a code
msg is a raw byte slice (&[u8], typically a byte-string literal like
b"done") — never a formatted string, since core::fmt/format! aren’t
used in hook code (see Anatomy of a Hook). code may be a
plain i64 literal, or any value whose type implements Into<i64> — which
is exactly what hook_errors! gives you below, so rollback!(msg, my_enum_variant)
works directly, without an explicit .code()/i64::from(..) call at the
call site.
Both macros expand to a call that, on the real wasm host, never returns —
execution unwinds immediately. That’s why rollback!’s return type is !
(the never type): it type-checks against whatever the surrounding
match/if arm needs to produce, including a typed entry’s own
HookResult — a branch that calls rollback! coerces to Ok/Err just
as readily as it coerces to a plain value, so an entry mixing ?-propagated
helpers with a direct rollback! call needs no placeholder return anywhere.
Designing meaningful error codes with hook_errors!
The code argument to rollback!/accept! isn’t discarded: xahaud
records it in the transaction’s metadata as
HookExecution.HookReturnCode. If every rejection path in a hook calls
rollback!(msg, -1) with the same code, nothing inspecting the transaction
afterwards — an indexer, a wallet, a support script — can tell why it was
rejected without parsing the message text. hook_errors! is how rshooks
hooks avoid that: one variant per rejection reason, each with its own
explicit, stable discriminant.
Here’s the worked example from examples/04_errors, a hook that rejects a
Payment for one of four distinct reasons:
use rshooks::hook_errors;
hook_errors! {
/// Rejection reasons returned by this hook.
pub enum RejectReason {
/// The originating account could not be read.
BadAccountField = -101,
/// The source tag is blocked.
BlockedSourceTag = -102,
/// The amount is not native.
NotNativeAmount = -103,
/// The amount exceeds the policy limit.
AmountTooLarge = -104,
}
}
hook_errors! expands this into a #[repr(i64)], Debug + Clone + Copy + PartialEq + Eq enum with the given variants and discriminants, plus:
impl From<RejectReason> for i64;- an inherent
fn code(self) -> i64— the same conversion as a method, for call sites that prefererr.code()overi64::from(err); impl From<RejectReason> for Rollback— unconditional, msg taken from an optional per-variant message clause (see “Accept,Rollback, and thehook_errors!message clause” above), so?works on anyResult<T, RejectReason>even though this example doesn’t use one.
Each variant requires an explicit i64-valued discriminant — the macro’s
grammar enforces this — and negative discriminants work the same as
positive ones, as in the example above. The example crate then adds a
small hand-written impl for the parts the macro doesn’t generate — a
message per variant, and a rollback convenience:
impl RejectReason {
fn message(self) -> &'static [u8] {
match self {
RejectReason::BadAccountField => b"errors: could not read otxn Account",
RejectReason::BlockedSourceTag => b"errors: blocked SourceTag",
RejectReason::NotNativeAmount => b"errors: unsupported (non-native) Amount",
RejectReason::AmountTooLarge => b"errors: amount exceeds policy limit",
}
}
fn rollback(self) -> ! {
rollback!(self.message(), self)
}
}
rollback!(self.message(), self) relies on the Into<i64> impl
hook_errors! generated: self (a RejectReason) converts through
i64::from on its way into the host call, with no .code() needed at the
call site. The hook body then runs a short chain of checks, calling
RejectReason::rollback() — the in-body escape hatch, straight from a
helper method deep in the call chain — the moment one fails:
#[hooks]
impl Errors {
#[hook(0, on = [Payment])]
fn main(&self) -> HookResult {
if otxn_field_typed(sfAccount).is_err() {
RejectReason::BadAccountField.rollback();
}
match otxn_field_u64(sfSourceTag) {
Ok(tag) if tag == u64::from(BLOCKED_SOURCE_TAG) => {
RejectReason::BlockedSourceTag.rollback()
}
_ => {}
}
let drops = match otxn_field_typed(sfAmount) {
Ok(AmountBytes::Native(n)) => u64::from_be_bytes(n.0) & !NATIVE_AMOUNT_FLAG_BITS,
Ok(AmountBytes::Iou(_)) | Err(_) => RejectReason::NotNativeAmount.rollback(),
};
if drops > MAX_DROPS {
RejectReason::AmountTooLarge.rollback();
}
Ok(Accept::from_code(0))
}
}
Because RejectReason::rollback returns !, each match/if arm that
calls it type-checks against whatever the other arms return — no
placeholder value needed anywhere in the chain, and the final
Ok(Accept::from_code(0)) only ever runs once every check has passed.
How the codes surface on-ledger
| Code | Reason | Message |
|---|---|---|
0 | (via Ok(Accept::from_code(0))) | 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.
It’s a spelling for reaching the same escape hatch accept!/rollback!
provide, not a separate mechanism from ?-propagation into HookResult —
pick whichever reads better at a given call site.
Where to go next
- Guards and Loops 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.
examples/16_typed-resultsis the full worked example for “Typed entry returns:HookResult” above, withrshooks build/checknumbers in itsREADME.mdand off-chain unit tests covering both the accept and?-rollback paths.
Guards and Loops
The Hook host statically rejects any wasm module containing a loop it
cannot prove terminates. Every loop — every one, including loops the
compiler generates that never appear as a loop keyword in your Rust
source — must call the host’s _g guard function at its top, declaring an
upper bound on its iteration count. This page covers guard! and
guard_m!, the loop-rotation pitfall that can move a correctly written
guard away from the top of the compiled loop, the compiler-generated-loop
pitfall that catches most people off guard the first time, and the
source-level idioms rshooks hooks use to avoid all of it.
Why every loop needs a guard
The Hook API’s static guard check exists so a malicious or buggy hook
can’t wedge a validator in an infinite (or merely too-expensive) loop
during transaction processing. Before a Hook binary can be installed, the
host’s guard checker walks every loop in the module and confirms it begins
with a call to _g(guard_id, maxiter) — a declaration of “this loop will
run at most maxiter times.” At runtime, _g tracks each guard id’s
actual iteration count as the hook executes, and the host aborts execution
with GUARD_VIOLATION if a loop ever exceeds the maxiter it declared.
rshooks exposes this through two macros that match the C GUARD/
GUARDM macros’ id and iteration-count formulas exactly, so the unsafe
call to _g lives inside the macro expansion — hook code never writes
unsafe for this.
guard! and guard_m!
guard!(maxiter) goes at the very top of a loop body:
use rshooks::guard;
let mut i = 0;
loop {
guard!(10);
if i >= 3 {
break;
}
i += 1;
}
assert_eq!(i, 3);
maxiter is the largest number of times this loop can possibly execute —
guard! itself adds + 1 internally to match the C macro’s exact formula,
so you supply the true iteration bound, not an off-by-one-adjusted value.
Choosing maxiter well means working from a bound you can actually justify
from the data’s shape: a fixed array’s length, a documented protocol
limit, or a value read from hook_param and validated before use — never
“a number that felt safe.” From examples/80_governance:
let mut i = 0u8;
while i < member_count {
guard!(u32::from(SEAT_COUNT)); // maxiter = 20, exact
let this_seat = i;
i = i.wrapping_add(1);
// ... reads the `IS<seat>` hook parameter for this seat ...
}
SEAT_COUNT is 20, a compile-time governance constant, and an earlier
check already rejects any member_count greater than it — so
maxiter = u32::from(SEAT_COUNT) is not just a safe bound, it’s the
exact worst case this loop can ever reach. A smaller value would be wrong
(a chain can configure up to 20 seats); a larger value would just inflate
the hook’s reported worst-case instruction count for no benefit.
guard_m!(maxiter, n) is for the rare case where two textually distinct
loops share one physical source line — guard!’s id formula,
(1 << 31) + line!(), would otherwise collide for both. The extra n
disambiguates them:
let mut i: usize = 0; let mut sum_a: u32 = 0;
loop { guard_m!(8, 1); /* ... */ }
let mut j: usize = 0; let mut sum_b: u32 = 0;
loop { guard_m!(8, 2); /* ... */ }
In real (non-teaching) code this situation arises from generated code —
a macro like rshooks::txn_template! that expands to more than one loop
at a single call site — rather than from manually cramming code onto one
line.
What $n does and doesn’t protect against, verified empirically by
examples/06_guard-patterns: giving both loops above the same n (so
they collide on one guard id) still passes rshooks build/check
without any error — the static checker only verifies loop shape (a guard
call at the top of every loop), never that ids are unique across the
module. The real hazard is a runtime one: _g tracks each guard id’s
iteration count as the hook actually executes, so two unrelated loops
sharing an id share one counter — whichever runs first pushes it toward
the other loop’s maxiter, risking a spurious on-ledger
GUARD_VIOLATION that no build-time tool catches; rshooks-testenv
reports it at unit-test time instead, since its own _g enforces the
same cumulative per-id budget. That’s the actual reason $n exists.
wasm-opt block-wrapping and LLVM loop rotation
A guard written correctly at a loop’s top in Rust source can still end up
somewhere other than the very first instruction after the compiled loop
opcode. Distinct compiler behaviors cause this, at different stages of
rshooks build’s pipeline: rshooks build compensates for one of them
automatically; the other needs a source-level idiom.
1. wasm-opt -Oz wraps the loop body in a block
rshooks build runs Binaryen’s wasm-opt -Oz size optimization first, on
each entry’s raw per-entry wasm, before cleaning (optimizer.rs). For a
loop whose body contains an internal early-exit branch — a continue, or
an if/? check on a Result a Hook API call returned — wasm-opt often
restructures it into loop { block { <guard>; .. } }: the break/continue
logic is wrapped in a block so a br/br_if can jump to its end, and
that block lands first inside the loop, ahead of the guard call that
was the loop body’s first statement in source. The guard itself is
untouched — still unconditional, still the first real instruction the
loop runs every iteration — only its position relative to loop moved,
which the checker’s exact loop; i32.const; i32.const; call $_g prologue
match doesn’t tolerate on its own.
rshooks build runs a guard-hoist pass (crates/rshooks-build/src/ guard_hoist.rs) after unnesting and before the guard check specifically to
undo this: it moves a guard prologue found just inside one or more leading
empty blocks back out to sit directly after loop, which is always
semantically identical (the prologue is stack-neutral and no label inside
the blocks targets it). This runs automatically, for every rshooks build/rshooks clean, with no source change required — a hook author
writing an ordinary loop { guard!(N); if !cond { break } body } or while cond { guard!(N); body } never needs to think about this case.
2. LLVM loop rotation moves the guard to the loop’s latch
Separately, at opt-level = 3, LLVM’s own loop-rotation pass can turn
either guard-writing form shown above into a do-while: it duplicates
the loop’s header block (the condition check) into the preheader ahead of
the loop, and moves the original header to the loop’s latch, after the
body, just before the branch back. Which source form stays “guard-first”
after this depends on which block LLVM treats as the header — an LLVM
decision the source doesn’t control:
loop { guard!(N); if !cond { break } body }’s guard is the first statement of the loop body, so it is the header. Rotation duplicates it into the preheader and moves the original pastbody, into the latch — the compiledloopopcode is then followed bybody, not by the guard, and the checker rejects it as missing a guard.while cond { guard!(N); body }’s condition is the header, with the guard as the body’s first statement. Rotation moves the condition to the latch, so the guard ends up leading the rotated loop — this passes.
But rotation only fires when LLVM judges the header “small” (a cheap
condition check); a large or expensive header (many arithmetic/memory
operations) is left un-rotated, and then it’s the while form that fails
instead. Neither fixed source form is guard-first under both outcomes, and
nothing in the source indicates which outcome a given loop will get — the
guard-hoist pass above can’t help here either, since there’s no leading
block to hoist out of: the guard is simply absent from the top of the
compiled loop, moved to its latch.
rshooks::guarded_while!(maxiter, cond, { body }) sidesteps the question
by placing a guard in both positions — the condition block and the top of
the body — so whichever one rotation leaves leading the compiled loop, that
block already starts with a guard call. The cost is one extra _g call per
iteration. See its rustdoc (crates/rshooks/src/macros.rs) for the full
mechanism and the guard-id convention it uses (guard_m! ids 1 and 2
on the macro’s own invocation line). continue inside its body jumps back
to the condition block, which is guarded, so it stays covered too.
Diagnosing which one you’re looking at
rshooks build’s unguarded-loop error names this shape directly when it
recognizes it — a _g call inside the loop’s body that isn’t at its head.
Since the build pipeline’s guard-hoist pass already runs before this check,
a report reaching you from rshooks build is case 2 (rotation): the block
case was already fixed automatically. rshooks check on an already-built
file calls the validator directly, with no hoist pass, so either cause is
still possible there.
To tell them apart by hand:
rshooks build --no-optimizeskipswasm-optentirely. If the same loop now passes, the failure was case 1 (wasm-optblock-wrapping) — LLVM’s own output was already guard-first. If it still fails, it’s case 2 (rotation).wasm-tools print <entry>.wasmon the raw per-entry wasm (see below for how to obtain it) shows the signature directly: a guard id (i32.const <id>) appearing twice — once immediately before theloopopcode (the duplicated preheader copy) and again partway through the loop’s body, at its latch — is rotation (case 2). A singleblockopener immediately afterloop, with the guard as the block’s first instruction, is thewasm-optshape (case 1) — already fixed by the timershooks buildreports anything, so this is only visible with--no-optimizeoff and inspecting an intermediate stage, or by disabling the hoist pass.
The raw per-entry wasm isn’t kept by default; rebuild it directly with the
cargo rustc invocation rshooks build itself uses, e.g.
cargo rustc --cfg rshooks_entry="0" --check-cfg 'cfg(rshooks_entry,values("0","1","2","3","4","5","6","7","8","9"))' --target wasm32v1-none --release -- -C link-arg=-zstack-size=<bytes>
(crates/rshooks-build/src/chain_build.rs’s selected_rustc_args/
cargo_args print the exact, current flags).
The compiler-generated-loop pitfall
The trap that catches most people writing Rust hooks for the first time:
some Rust operations lower to a call into a compiler_builtins function
containing a real, unguarded loop, even though no loop appears in your
source at all. On wasm32v1-none (the WASM MVP target, with no
bulk-memory instructions), this happens for:
- Fixed-size array/slice equality —
[u8; N] == [u8; N]lowers to abcmp-style byte-compare loop. (The protocol newtypes inrshooks::types—AccountId,Hash,Keylet, and the rest — are the exception: theirPartialEqis hand-written to call the matchingbuf_eq_*internally, so==between two of them is already loop-free. This pitfall is specifically about comparing bare[u8; N]arrays.) - Large buffer zero-init or copy — a big stack-local
[0u8; N], or a largememcpy-shaped copy, lowers to amemset/memcpy-style loop.
rshooks build treats an unguarded loop as a hard build error — missing
a guard! in your own code is a bug, not something to silently paper
over — so a compiler-generated loop like this needs a source-level fix,
not a build flag. examples/05_firewall/src/lib.rs compares accounts with
buf_eq_20 explicitly for exactly this reason, and AccountId’s own ==
is itself already loop-free too (see the callout above), since its
PartialEq delegates to buf_eq_20 internally.
The two idioms that avoid it
Two source-level idioms sidestep the compiler-generated loop entirely, and are preferred wherever they apply:
Fixed-size buffer equality — rshooks::buf_eq_8/_20/_32/_33/
_34/_40/_48/_64 compare a buffer as a fixed sequence of word-sized
(u64, with a narrower tail word where the size isn’t a multiple of 8)
chunks, built from source-level literal byte indices. The comparison is
genuinely straight-line code — there is nothing for LLVM to lower into a
loop:
if buf_eq_20(&sender, &blocked) {
rollback!(
b"guard-patterns: blocked account",
GuardPatternsError::BlockedAccount
);
}
firewall’s account comparison calls buf_eq_20 for this reason: it
removes the compiler-generated loop entirely, and the word-at-a-time
comparison keeps its worst-case instruction count well below a derived
array comparison’s. AccountId’s own == delegates to buf_eq_20 as
well (see the callout above), so either spelling is loop-free.
Statics for templates and large buffers — covered in
Anatomy of a Hook:
HookStatic moves a template or large buffer into a data segment or BSS
instead of runtime store chains or a memset loop, which removes the
memcpy/memset-shaped compiler-generated loop the same way buf_eq_*
removes the bcmp-shaped one. Applying this idiom to emit-txn removed
its only compiler-generated loops entirely and cut its worst-case
instruction count by an order of magnitude (6798 → 331, at this
toolchain’s opt-level = 3 default — exact numbers drift a little
between compiler versions).
A nested-guarded-loop pitfall: unrolling that duplicates the inner loop
The worst-case-instruction-count model a nested guard! relies on assumes
each guard! call site compiles to exactly one physical loop in the final
module: an inner loop’s cost is meant to be amortized across every
iteration of its outer loop (the outer loop’s own guard! already bounds
how many times that can happen), so the checker only has to charge that
inner loop’s cost once, at the multiplier its maxiter implies relative
to its parent’s.
At opt-level = 3, that assumption can quietly break. LLVM routinely
fully unrolls a small, provably-bounded outer loop (2 or 3 iterations is a
typical threshold) whenever it judges duplicating the body worthwhile —
independent of whether that body is written inline or behind a function
call, since Guard-type hooks force-inline every reachable function into
hook()/cbak() regardless (docs/DESIGN.md §6.2b), so unrolling and
inlining compound. When the outer loop wraps a guard!-protected inner
loop, unrolling physically duplicates that inner loop once per outer
iteration. The checker walks the compiled bytecode, not the source, so
it then counts the inner loop’s full worst-case cost once per duplicate
instead of once total — silently multiplying, not amortizing, its
contribution to the worst-case instruction count.
The actual driver is what happens to the parent loop, not the duplicate
count by itself: the checker’s multiplier for a loop is its own maxiter
divided by whatever its immediate parent’s iteration bound is, so
duplicated copies that stay nested under a real parent loop are still
charged at that parent-divided rate (three copies nested under a
guard!(2) outer loop each cost 66/2, the same total as one copy would)
— no penalty from duplication alone. The penalty shows up specifically
when unrolling removes the parent loop entirely: each duplicate then sits
at the top level, with no parent to divide by, so its multiplier jumps
from 66/2 to the loop’s full, undivided 66/1 — once per duplicate.
examples/80_governance hit this directly: an outer 2-iteration table
loop wrapping a guard!(66)-bounded 32-topic scan got fully unrolled,
doubling the inner loop’s measured cost — worth roughly a third of the
whole govern entry’s worst-case instruction count. rshooks::no_unroll
fixes it by routing the outer loop’s induction variable through
core::hint::black_box at its comparison, which makes the trip count
opaque to the optimizer and keeps the loop as one real loop construct:
use rshooks::{guard, no_unroll};
let mut tbl = 1u8;
while no_unroll(tbl) <= 2 {
guard!(2);
// ... the inner guard!(66)-protected loop goes here, once ...
}
This is the mirror image of the compiler-generated-loop pitfall above:
that section is about the compiler turning no loop into one that needs a
guard; this one is about the compiler turning one guarded loop into
several, silently. Only reach for no_unroll at a call site actually
exhibiting this shape — an outer loop, itself small enough to be a
plausible full-unroll candidate, wrapping further guard!-protected work
— applying it to every guarded loop by default would regress the common
case, where full unrolling is cheaper (straight-line code has no loop
overhead and no worst-case-padding waste). See no_unroll’s own doc
comment (crates/rshooks/src/macros.rs) for the full reasoning, including
its failure mode if a future toolchain ever stopped honoring
black_box’s optimization-barrier hint.
Where to go next
- Anatomy of a Hook covers the
HookStaticidiom in full, including the safety argument for its take-once exclusivity. - Accept, Rollback, and Errors covers how a hook actually terminates once its checks — guarded loops included — are done.
- The rshooks CLI covers the full
build/clean/checkflag reference.
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 their entry’s declared on/on_incoming+on_outgoing list
already (see Per-Hook Attributes), so otxn_type
is typically used for a final sanity check or to branch between a handful of
expected types within one hook.
Reading a field: the typed path is the default
rshooks generates a table of typed field constants in the sfield
module — one SField<T> per sfXxx code, each carrying the Rust type that
field’s value decodes as. sfAccount is an SField<AccountId>,
sfSequence an SField<u32>, sfAmount an SField<Amount>.
otxn_field_typed reads a field using exactly that pairing — no
turbofish, no separate decode step, and no way to accidentally decode the
wrong field as the wrong type (the constant itself pins down the return
type):
use rshooks::prelude::*;
let sender: Result<AccountId> = otxn_field_typed(sfAccount);
let sequence: Result<u32> = otxn_field_typed(sfSequence);
This is the default way to read any field the generated sfield table
gives a value type to. Narrow integers (u8/u16/u32) go through the
host’s as-int64 mode; u64 and every fixed-byte type (Hash, AccountId,
CurrencyCode) read their exact wire bytes; Amount and Issue are
classified by length and come back as AmountBytes/IssueData rather than
a single scalar, since their wire encoding is one of two shapes:
use rshooks::prelude::*;
match otxn_field_typed(sfAmount) {
Ok(AmountBytes::Native(drops)) => {
// `drops.0` is the raw 8-byte big-endian wire encoding — see
// "Decoding a raw field" below for why it's `from_be_bytes`, not
// this crate's `FromBytes` trait.
let _ = drops;
}
Ok(AmountBytes::Iou(_)) => { /* an IOU amount */ }
Err(_) => { /* field missing or unreadable */ }
}
examples/03_hook-params uses exactly this pattern to reject any
non-native Amount:
let drops = match otxn_field_typed(sfAmount) {
Ok(AmountBytes::Native(n)) => u64::from_be_bytes(n.0) & !NATIVE_AMOUNT_FLAG_BITS,
Ok(AmountBytes::Iou(_)) | Err(_) => rollback!(
b"hook-params: unsupported (non-native) Amount",
HookParamsError::UnsupportedAmount
),
};
(The top two bits of a serialized native amount are format flags, not part of the drops value, hence the mask.)
Not every field has a modeled value type — Blob, STObject, STArray,
and a handful of others map to Opaque, which supports navigation but no
single scalar value(). For those, or when you just want the raw wire
bytes, reach for the two escape hatches below.
The raw escapes: otxn_field and otxn_field_exact
otxn_field reads a field into a caller-provided buffer and returns the
number of bytes written — the least opinionated option, usable for any
field regardless of whether this crate models a typed read for it:
use rshooks::prelude::*;
let mut buf = [0u8; 20];
let written = otxn_field(&mut buf, sfAccount)?;
otxn_field_exact is the fixed-length middle ground: it requires the
field to be exactly T’s length (any FixedRead type — a
rshooks::types newtype, or a raw [u8; N]), with T inferred from
context rather than a turbofish:
use rshooks::prelude::*;
let sender: AccountId = otxn_field_exact(sfAccount)?;
let raw_sequence: [u8; 4] = otxn_field_exact(sfSequence)?;
There’s also otxn_field_u64, the as-int64 escape hatch for a field of at
most 8 bytes with the top bit clear — the same convention
otxn_field_typed’s narrow-integer impls use internally.
Decoding a raw field: from_be_bytes, not FromBytes
This is the one trap worth calling out explicitly. The bytes
otxn_field/otxn_field_exact hand back are Xahau Binary — the
protocol’s own big-endian wire format — never this crate’s little-endian
FromBytes trait, which is the convention for hook-private data (state
and parameter values this crate’s own typed layer wrote, covered in
Hook State and Typed Data with Derives). A
numeric protocol field read through a raw escape needs an explicit
u64::from_be_bytes(...) at the call site — exactly what the
hook-params example above does for sfAmount’s native drops value.
otxn_field_typed already does this decoding itself for every field it
models, so a field with a modeled type never needs the idiom at all.
The transaction ID
otxn_id writes the originating transaction’s hash into a caller buffer;
otxn_id_buf is the fixed-size convenience twin that returns a Hash
directly:
use rshooks::prelude::*;
let id: Hash = otxn_id_buf(0)?;
flags = 0 prefers the emit-failure transaction ID where applicable; other
flag values pass through verbatim to the host.
Burden and generation
For an emitted transaction (one a hook itself created via emit, see
Emitting Transactions), otxn_burden and
otxn_generation report the emit chain’s burden and depth; for a normal,
directly-submitted transaction they read back 1 and 0 respectively.
A worked example: the firewall pattern
examples/05_firewall is a compact illustration of the typed field path
end to end — read the sender as an AccountId, compare it against a
configured blocklist, and roll back on a match:
#[hooks]
impl Firewall {
#[hook(0, on = [Payment])]
fn main(&self) -> HookResult {
let Ok(sender) = otxn_field_typed(sfAccount) else {
rollback!(
b"firewall: could not read otxn sender",
FirewallError::CouldNotReadSender
)
};
let Some(blocked) = blocked_account() else {
accept!()
};
// `AccountId`'s `==` is loop-free too, but spelling this as
// `buf_eq_20` makes the loop-free mechanism explicit.
if buf_eq_20(&sender, &blocked) {
rollback!(b"firewall: blocked account", FirewallError::BlockedAccount);
}
accept!()
}
}
otxn_field_typed(sfAccount) reads back an AccountId directly — no
turbofish, no manual length check. Note the comparison: AccountId’s ==
is itself loop-free (it delegates to buf_eq_20 internally), so sender == blocked would work here too; the example spells it as buf_eq_20(&sender, &blocked) to make that loop-free-by-construction property explicit at the
call site rather than relying on a type’s PartialEq impl. On a bare [u8; 20] buffer (not one of these typed newtypes), == would compile to a
byte-compare loop the Hook API’s guard checker would need a guard! for
(see Guards and Loops); buf_eq_20 is loop-free by
construction (every byte index is a source-level literal) and sidesteps the
issue entirely regardless of type. blocked_account() reads a Hook
parameter declared on the Firewall struct — see Hook and Transaction
Parameters.
Reading several fields of a known format: views
Once the transaction’s type is established (by otxn_type(), or by the
hook’s own on/on_incoming+on_outgoing list), rshooks::views::tx gives
named accessors for every field the protocol declares on that format instead
of one otxn_field_typed(sfXxx) call per field:
use rshooks::views::tx;
let Ok(payment) = tx::Payment::otxn() else {
rollback!(b"not a Payment", ...)
};
let dest = payment.destination()?; // sfDestination
let amount = payment.amount()?; // sfAmount, as AmountBytes
Payment::otxn() itself is one otxn_type call plus an integer compare, and
every accessor after it is exactly the otxn_field_typed call it would
otherwise take by hand — the view adds named fields, not a different read
path. See Typed Views for the full picture, including which
formats need the active-amendments/all-amendments feature.
Nested fields: slots
Everything on this page reads a top-level field of the originating
transaction directly. A field nested inside an object or array (an entry in
a Memos array, a field of a SignerListSet signer entry, …) needs
slot-based access instead — load the transaction into a slot with
otxn_slot and navigate from there. See Slots and Ledger
Objects for the full slot API.
Hook State
A Hook’s persistent storage is a flat key-value store scoped to the
account it’s installed on (and, for a foreign read, another account’s
namespace too). rshooks gives you three tiers of access to it, from a raw
buffer read all the way up to a struct field with generated typed accessor
methods. This page walks through all three, plus reading another account’s
state. If you haven’t read Typed Data with Derives
yet, the #[derive(HookKey)]/#[derive(HookData)] derives it covers are
what the higher tiers here are built on.
The state model: 32-byte keys, host-side left-padding
Every state entry is addressed by a key of up to 32 bytes. The Hook API
accepts a key from 1 to 32 bytes and left-pads a shorter key internally
to its own fixed-width storage slot — the same idiom a C hook uses when it
calls state(&v, 8, "RR", 2) with a 2-byte literal key. rshooks mirrors
this at every layer: a short key is sent to the host at its own real,
unpadded length, never locally zero-padded to 32 bytes. Values, by
contrast, are read and written as plain bytes with no implied structure —
interpreting them is entirely up to the layer you’re using.
Tier 1: the loose, single-value API
rshooks::api::state (re-exported by the prelude) is the lowest-level
typed convenience over the raw state/state_set host calls: a family of
small functions for exactly the primitive cases, each taking a raw
&[u8]-like key with no key-type story of its own.
use rshooks::prelude::*;
let mut buf = [0u8; 32];
let key = [0u8; 32];
let written = state(&mut buf, &key)?;
state_set(&buf[..written], &key)?;
For the common primitive shapes there are dedicated helpers —
state_u32/state_set_u32, state_i64/state_set_i64,
state_xfl/state_set_xfl, and their state_update_* read-modify-write
counterparts — all little-endian via state_exact under the hood. The one
outlier is state_u64/state_update_u64, which use the host’s as-int64
mode and read/write big-endian — intended for an entry whose bytes
originated from Xahau Binary itself (a protocol-mirroring value, or interop
with a C hook), not one this crate’s own typed layer wrote. For a
little-endian u64 written by the typed layer, use state_u64_le instead.
state_exact::<T> is the general fixed-length escape hatch this tier is
built on, identical in spirit to otxn_field_exact (see Reading the
Originating Transaction): T must be exactly the right length,
inferred from context, no turbofish.
Reach for this tier for a one-off primitive read/write with no reuse value, or as the escape hatch Hook Chains covers for a hook whose typed-accessor call-site density has outgrown its nesting budget. For a hook with more than a couple of distinct state entries, the next two tiers pay off quickly.
Tier 2: state_keys! — a typed key enum, independent value type
crate::state’s state_get/state_set_loose/state_update_loose work
for any type implementing ToBytes/FromBytes — not just the
primitives Tier 1 hard-codes — paired with a state_keys!-declared enum
for the key side:
use rshooks::prelude::*;
use rshooks::state_keys;
state_keys! {
/// This hook's persistent data.
enum DataKey {
/// A running counter.
Counter,
/// A per-owner balance, keyed by the owner's account.
Balance(AccountId),
}
}
let count: Option<u64> = state_get(&DataKey::Counter)?;
state_set_loose(&DataKey::Counter, &1u64)?;
A unit variant (Counter) encodes to just its 1-byte discriminant, no
padding at all. A tuple variant (Balance(AccountId)) carries exactly one
ToBytes payload, encoded at runtime as “discriminant byte + payload,”
again with no trailing padding — the real length sent to the host is 1 + Payload::MAX_LEN. Declaration order matters: the macro assigns each
variant a sequential u8 discriminant, so inserting or reordering a
variant changes every later variant’s encoded key (and thus which on-chain
slot it addresses).
state_get/state_set_loose/state_update_loose still take the key and
the value type as independent generic parameters, though — nothing stops
calling state_get::<SomeOtherType>(&DataKey::Counter) for a pairing that
was never intended, as long as SomeOtherType: FromBytes (true of nearly
every fixed-size type this crate provides). That’s exactly the gap Tier 3
closes.
For a value type over Tier 1/2’s 32-byte cap, a state_keys! key still
pays off against the raw state/state_set calls directly via
StateKeyEncode::with_key_bytes — see Keylets’s worked
example.
Tier 3: #[state(...)] struct fields — a key permanently paired with its value type
A field on a #[hooks] struct (see Anatomy of a Hook)
can declare a hook-state entity: a key bound to exactly one value type,
via a State<V> field carrying a #[state(...)] attribute. There is no
second, independently-chosen value type left for a mismatch to hide in —
passing the wrong value where this field’s V is expected is a compile
error.
The attribute has exactly two forms, because the key’s shape is carried
by an ordinary Rust type (S::KeyArgs, resolved through the field
generated for it) rather than needing its own bespoke struct declaration:
| form | key shape | example |
|---|---|---|
key = <expr> | fully fixed — a constant expression whose type implements key-encoding (a byte-string literal works directly) | #[state(key = b"RR")] |
key_by = <TypePath> | keyed — constructed per call site from any type already implementing StateKeyEncode (a #[derive(HookKey)] struct, a state_keys! enum, or a primitive array type) | #[state(key_by = DepositKey)] |
key = ...: a fixed key
use rshooks::*;
#[hooks]
pub struct StateCounter {
/// Persistent invocation counter, stored at the fixed key `"counter"`.
#[state(key = b"counter")]
counter: State<u64>,
}
declares a field named counter, addressed by the fixed key b"counter",
holding a u64. Because the struct has a named field, the macro also
generates a static value named after the struct (StateCounter, same
name, different namespace — see Anatomy of a Hook).
An entry (or a helper inside the same #[hooks] impl) declares &self to
receive that static and calls the field’s accessors as
self.state.counter.get(); code outside the impl reaches the identical
static by the struct’s own name instead: StateCounter.state.counter.get().
key also accepts a const reference to something more structured than a
literal, as long as it encodes:
const ENABLED_KEY: StateKey = StateKey(pad!(b"enabled"));
#[hooks]
pub struct StateForeign {
#[state(key = &ENABLED_KEY)]
enabled: State<[u8; 1]>,
}
key_by = ...: a key constructed per call site
Use this when the key varies at runtime — keyed by the calling account, for example:
#[derive(HookKey, Clone, Copy)]
struct DepositKey {
tag: u8,
owner: AccountId,
}
#[hooks]
pub struct TypedData {
#[state(key_by = DepositKey)]
deposits: State<DepositValue>,
}
deposits on its own is the field, not yet addressed to a specific
entry — call .at(args) to bind the key’s runtime arguments and get a
handle with the same accessor set. Inside the #[hooks] impl, an entry
reaches it as self.state.deposits:
#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
let deposit = self.state.deposits.at(DepositKey { tag: DEPOSIT_TAG, owner });
let current = match deposit.get() {
Ok(existing) => existing.unwrap_or(EMPTY_DEPOSIT),
Err(_) => rollback!(
b"typed-data: state read failed",
TypedDataError::StateReadFailed
),
};
// ...
if deposit.set(&next).is_err() {
rollback!(
b"typed-data: state_set failed",
TypedDataError::StateSetFailed
);
}
}
.get()/.set() return rshooks::error::Result (HookError); there is
no From<HookError> for Rollback, so ? on those calls inside a
-> HookResult entry does not compile. Convert with match/rollback!
as above, or see Accept, Rollback, and
Errors for the ? + hook_errors! +
.map_err(|_| MyError::…) pattern.
DepositKey here is any type that already implements StateKeyEncode —
most often a #[derive(HookKey)] struct (see Typed Data with
Derives) or a state_keys! enum, exactly the same key
types Tier 2 uses, so a key shape you’ve already declared for Tier 2 slots
directly into key_by with no redeclaration.
The generated accessors
Every #[state(...)] field — used directly for key = ..., or through
.at(args) for key_by = ... — gets the same six methods:
| method | signature | behavior |
|---|---|---|
.get() | Result<Option<V>> | Ok(None) for “no entry”; a genuine decode failure or host error is Err, never confused with absence. |
.set(&value) | Result<usize> | Writes value, returning the byte count written. |
.update(f) | Result<usize> where f: FnOnce(Option<V>) -> V | Reads (Option<V>, same absence handling as .get()), applies f, writes the result — one round trip. |
.delete() | Result<()> | See “Deleting an entry” below. |
.get_foreign(ns, acct) | Result<Option<V>> | Same as .get(), but on another namespace/account — see “Foreign state” below. |
.set_foreign(&value, ns, acct) | Result<usize> | Same as .set(), foreign-addressed. |
These are thin, #[inline(always)] forwards to the same underlying
functions Tier 1/2 call (state_get, state_set_loose, and so on) — the
struct field’s job is purely to fix the key and value type together at the
declaration site, not to introduce a new code path.
The counter walkthrough
examples/02_state-counter is the smallest complete tutorial for the typed
layer:
#[hooks]
pub struct StateCounter {
#[state(key = b"counter")]
counter: State<u64>,
}
#[hooks]
impl StateCounter {
#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);
let next = count.wrapping_add(1);
if self.state.counter.set(&next).is_err() {
rollback!(
b"state-counter: state_set failed",
StateCounterError::StateSetFailed
);
}
Ok(Accept::new(b"state-counter: incremented", next as i64))
}
}
main declares a &self receiver, so self.state.counter.get() returns
Result<Option<u64>>: Ok(None) means “no entry yet” (see below), so the
double unwrap_or handles both “never written” and “an unexpected read
error” the same way, defaulting to zero either way.
Ok(None) means “no entry” — never a special-cased error
Every typed read here maps “no entry for this key” to Ok(None), the same
shape as HashMap::get — ordinary, not exceptional. Every other error,
including a present-but-undersized entry that fails to decode as T,
still comes back as Err, so a genuine decode failure is never mistaken
for “nothing was ever stored here.”
Deleting an entry
The Hook API has no dedicated “delete” call — an entry is deleted by
writing zero bytes to it, which also refunds the owner reserve it was
holding. .delete() is the explicit spelling for that, independent of any
value type — deliberately not reachable by pairing a key with a value type
that happens to encode to nothing, which would spell “delete” as an
accident of the value type rather than an intent at the call site.
examples/12_typed-data deletes a depositor’s record on full withdrawal
for exactly this reason (releasing the reserve, rather than leaving a
zeroed entry behind):
if deposit.delete().is_err() {
rollback!(
b"typed-data: state_set failed",
TypedDataError::StateSetFailed
);
}
Foreign state: reading another account’s entries
.get_foreign(ns, acct)/.set_foreign(&value, ns, acct) (and the raw-tier
state_foreign/state_foreign_get/state_foreign_get_typed free
functions they forward to) read or write a state entry belonging to
another account, or another namespace on this hook’s own account.
namespace/account are Option<&[u8]>, defaulting to “this hook’s own”
when passed None.
examples/09_state-foreign reads a flag from a target account configured
via a Hook parameter:
#[hooks]
pub struct StateForeign {
/// The target account whose flag this hook reads (`ACCT`).
#[hook_param(name = b"ACCT", required)]
acct: HookParam<AccountId>,
/// The target account's flag, read via `get_foreign` under
/// [`ENABLED_KEY`] in this hook's own namespace.
#[state(key = &ENABLED_KEY)]
enabled: State<[u8; 1]>,
}
#[hooks]
impl StateForeign {
#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
let Ok(target) = self.hook_param.acct.get_required() else {
rollback!(
b"state-foreign: ACCT parameter not configured",
StateForeignError::AcctNotConfigured
)
};
let flag = match self
.state
.enabled
.get_foreign(None, Some(target.as_ref()))
{
Ok(Some(v)) => v,
Ok(None) => rollback!(
b"state-foreign: not configured on target account",
StateForeignError::NotConfiguredOnTarget
),
Err(_) => rollback!(
b"state-foreign: state_foreign read failed",
StateForeignError::ReadFailed
),
};
if flag[0] == 0 {
rollback!(
b"state-foreign: target account's flag is off",
StateForeignError::FlagOff
);
}
Ok(Accept::from_code(0))
}
}
Passing namespace = None and account = Some(target.as_ref()) reads the
entry keyed ENABLED_KEY in this hook’s own namespace, but on
target’s account — the shape for “the same hook code, installed on
account A and account B, where A wants to read a flag B’s copy of the hook
maintains about itself.” get_required() (covered in Hook and
Transaction Parameters) is this example’s way of turning a
missing ACCT parameter into an immediate, distinct rollback reason,
distinct from ACCT being present but the target having no matching state
entry (Ok(None) from get_foreign).
State interface (typed on-ledger schema)
This section’s surface requires the
unstable-state-interfacefeature:rshooks = { version = "…", features = ["unstable-state-interface"] }.unstable-*features track draft specs and are exempt from semver — breaking changes may land in a minor release while the spec is a draft.
Every tier above pairs a key with a value type entirely inside rshooks —
nothing about that pairing is visible on-ledger. The Hook State
Interface is a different, protocol-level convention: a HookParameters
convention that exposes a Hook’s state layout as a machine-readable, typed
key/value schema, so an external, schema-aware client can decode a Hook’s
state without knowing anything about the hook’s own source. The normative
rules live in docs/STATE_INTERFACE_DESIGN.md; this section covers the
day-to-day surface built on it.
#[hooks]
pub struct Treasury {
#[state_interface(id = 0, key(account: AccountId, token: u32),
value(amount: u64, updated: u32))]
balances: State<Balance>,
#[state_interface(id = 1, value(paused: u8))]
paused: State<Config>,
}
(the design doc’s own worked example, also examples/20_state-interface.)
#[state_interface(..)] lives in the same State namespace as an ordinary
#[state(..)] field — self.state.balances/self.state.paused — and the
field still works through the same .at(..)/.get()/.set()/.update()/
.delete() accessors this whole page covers. What’s different: Balance/
Config are not written by hand — the macro generates them from the
value(..) schema — and the struct’s #[hooks] carrier gains a
machine-readable description of both entries’ on-ledger layout, which
rshooks-build turns into HookParameters declaration entries in
sethook.template.json (see Metadata and the SetHook
Template).
Declaration grammar
id = <0..=255>— the State ID, required, unique across every#[state_interface]field on the struct (identifiers, not positional indexes — contiguity isn’t required).key(name: Type, ..)— ordered key fields, optional; omitted means a singleton (no key at all, direct.get()/.set()with no.at(..)).value(name: Type, ..)— ordered value fields, required, at least one.- The field’s own type must be
State<VName>, whereVNameis a bare identifier the macro generates astructfrom — not an existing type. - Every field name is
[A-Za-z][A-Za-z0-9]*, 1 to 16 bytes (no_— same rule the signature parameter interface’s argument names follow).
Supported types
Version 0 of the interface supports only fixed-width types — the rows the
signature-parameter interface’s own table draws from, minus the
variable-width ones (AmountBytes, Blob<N>, IssueBytes) that interface
supports and this one does not:
| Rust type | type code | width |
|---|---|---|
u8 | 0x10 (STI_UINT8) | 1 |
u16 | 0x01 (STI_UINT16) | 2 |
u32 | 0x02 (STI_UINT32) | 4 |
u64 | 0x03 (STI_UINT64) | 8 |
[u8; 16] | 0x04 (STI_UINT128) | 16 |
[u8; 32] / Hash | 0x05 (STI_UINT256) | 32 |
AccountId | 0x08 (STI_ACCOUNT) | 20 |
[u8; 20] | 0x11 (STI_UINT160) | 20 |
CurrencyCode | 0x1A (STI_CURRENCY) | 20 |
XFL | 0x80 (XFL) | 8 |
XFL’s value is the Hook API’s int64_t XFL bit pattern, big-endian —
XFL::raw_bits() — decoded via XFL::from_raw_bits with no validity
check; the type codes themselves come from XAS-010d (Hook Type Codes),
which both this interface and the Hook Parameter Signature Interface
reference for their type codes. XFL is fixed-width, so it is usable as
a key field as well as a value field.
Every key/value field’s type is pinned against alias drift by a
monomorphized const assert on rshooks::si::SiFieldType::TYPE_BYTE
(token-level type checks are alias-forgeable) — the same defense
SigParamType::TYPE_BYTE provides for signature parameters.
The generated key and value bytes
Unlike the rest of this page, a #[state_interface] key is exactly 32
bytes, always: StateID || Encode(K0) || Encode(K1) || .. || zero padding
— the interface fixes the physical key layout as part of its wire
contract, so rshooks builds the full 32-byte key locally and sends all 32
bytes, rather than relying on the host’s own left-pad convention this
page’s other tiers use. A singleton’s key is StateID || 31 zero bytes,
promoted to a 'static compile-time constant the same way a literal
#[state(key = b"...")] is.
The generated value struct’s fields are encoded big-endian (not this
crate’s ordinary little-endian HookData convention) and concatenated
directly — no field IDs, separators, or length prefixes — because a state
interface value is protocol-facing, schema-aware-client-readable data, the
same rationale signature parameters’
big-endian convention follows. There’s no compile-time cap on a value
schema’s total encoded length (unlike the key, or the declaration’s own
HookParameterName/HookParameterValue bounds) — it must fit the
installing account’s own Hook State data size limit, an account-dependent
write-time constraint the interface itself leaves for the host to enforce.
The design doc’s own worked spec vector, for account = 4B4E9C06F24296074F7BC48F92A97916C6DC5EA9, token = 42, amount = 1000, updated = 12345:
HookStateKey:004B4E9C06F24296074F7BC48F92A97916C6DC5EA90000002A00000000000000HookStateData:00000000000003E800003039
See crates/rshooks-testenv/tests/state_interface.rs for this vector
driven end-to-end through TestEnv::invoke, asserted against the raw
stored bytes.
Declarations are advisory metadata
Nothing about #[state_interface] changes what the host enforces: a
declared field still goes through the same accessors as an ordinary
#[state(..)] field, which the host accepts unconditionally. The wire
format only shapes what a schema-aware client can expect a conforming
hook to have written — nothing stops a hook from advertising a schema and
then writing something else, same as any machine-readable interface layered
on top of an otherwise-untyped protocol.
Where to go next
Every typed value type on this page — the u64 in the counter example, the
DepositValue struct, AccountId as a DepositKey field — is either a
primitive rshooks already implements ToBytes/FromBytes for, or a
struct built with #[derive(HookKey)]/#[derive(HookData)]. See Typed
Data with Derives for how those derives work, their exact
byte layout, and why they cost nothing over hand-packing. See Hook
Chains for how a #[state(...)] field declared
once is shared across every Hook entry in the same chain.
Hook and Transaction Parameters
Hooks read configuration from two distinct sources, both called
“parameters” but attached at very different times: hook parameters,
set once when the hook is installed (via SetHook), and originating
transaction parameters, attached fresh by whoever submits the triggering
transaction. This page covers both — the loose byte-buffer accessors, the
#[hook_param(...)]/#[otxn_param(...)] struct-field attributes that pair
a name with a value type, and composite (struct-shaped) names via
#[derive(ParamName)].
Two sources, one shape
| Hook 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 |
| struct field | #[hook_param(...)] field: HookParam<V> | #[otxn_param(...)] field: OtxnParam<V> |
| 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 field
attribute grammar and accessor shape; only the underlying host call
differs.
The loose accessors
hook_param/otxn_param read a named parameter into a caller-provided
buffer, mirroring otxn_field’s shape (see Reading the Originating
Transaction):
use rshooks::prelude::*;
let mut buf = [0u8; 32];
let written = hook_param(&mut buf, b"CFG")?;
hook_param_exact/otxn_param_exact require the parameter to be exactly
T’s length (any FixedRead type), with T inferred from context, not a
turbofish. A typical use is a compiled-in default when the operator hasn’t
configured a minimum:
const THRESH_PARAM: &[u8] = b"THRESH";
const DEFAULT_THRESHOLD: u64 = 1_000_000;
fn threshold() -> u64 {
hook_param_exact(THRESH_PARAM)
.map(u64::from_be_bytes)
.unwrap_or(DEFAULT_THRESHOLD)
}
hook_param_exact’s return type is inferred as [u8; 8] from the
.map(u64::from_be_bytes) call — no turbofish needed. Note the
from_be_bytes, not this crate’s FromBytes trait: a raw parameter byte
buffer is whatever the caller who set it chose to write, and this tier
leaves that byte convention entirely up to whatever wrote it — here
chosen (by this snippet, not the crate) to match Xahau Binary’s
big-endian numeric encoding, the same convention Reading the Originating
Transaction describes for raw protocol fields.
.unwrap_or(DEFAULT_THRESHOLD) collapses “not configured at all” and
“configured with a value of the wrong size” into the same fallback,
without treating a malformed parameter as a hard error. This tier needs
no field declaration on the #[hooks] struct at all — reach for it for a
one-off read, or as the escape hatch Hook
Chains
covers when accessor density at one call site outgrows the nesting
budget. The declared-field tier below decodes every value through this
crate’s FromBytes trait instead, so its byte convention is always
little-endian, fixed by the crate rather than chosen per call site — see
examples/03_hook-params’s MIN parameter for a worked example.
Struct fields: #[hook_param(...)] / #[otxn_param(...)]
hook_param_exact/otxn_param_exact take the name and the value type T
as two independent arguments — nothing stops calling
otxn_param_exact::<WrongType>(b"T") for a name/type combination that
was never intended, as long as WrongType: FixedRead (true of nearly
every fixed-size type this crate provides, including some other
parameter’s value type). A field on the #[hooks] struct closes that gap
by declaring a name permanently paired with one value type:
#[hooks]
pub struct Firewall {
/// The blocked account, configured via the `BL` Hook parameter.
#[hook_param(name = b"BL")]
blocked: HookParam<AccountId>,
}
The attribute grammar is the same for both #[hook_param(...)] and
#[otxn_param(...)] — only the field’s type (HookParam<V> vs.
OtxnParam<V>) picks which host call the generated accessors read
through:
| argument | meaning |
|---|---|
name = <byte-string literal> | a fixed, literal name — free at runtime, since the wire encoding is the literal’s own bytes |
name_by = <TypePath> | a composite name, constructed per call site — see “Composite names” below |
required | adds .get_required() (mutually exclusive with default) |
default = <expr> | adds .get_or_default(), falling back to <expr> (mutually exclusive with required) |
The accessors, and what “absent” actually means
Every field gets .get() -> Result<Option<V>> unconditionally; required
and default each add one more method, and are mutually exclusive because
they answer the same question — “what happens when this parameter isn’t
set?” — two different ways:
| method | available | behavior on absence | behavior on a present-but-malformed value |
|---|---|---|---|
.get() | always | Ok(None) | Err |
.get_or_default() | with default = <expr> | Ok(<expr>) | Err — not silently replaced by the default |
.get_required() | with required | Err (a dedicated “missing” error, distinct from a decode error) | Err |
The load-bearing rule, worth stating precisely: “absent” is decided
before any decoding happens, from the host API’s own “doesn’t exist”
signal — never inferred after the fact from a decode failure. A parameter
that is set, but to the wrong number of bytes for V, is a decode
failure, and .get_or_default() reports that as Err rather than quietly
substituting the default. If you want “any read failure at all, absence or
malformed, falls back to the same value,” write that explicitly at the
call site instead, the same way examples/05_firewall does:
fn blocked_account() -> Option<AccountId> {
Firewall.hook_param.blocked.get().ok().flatten()
}
.ok() turns Err into None, and .flatten() collapses the resulting
Option<Option<AccountId>> down to one level — deliberately masking a
decode failure the same way as absence, rather than treating a malformed
BL value as anything worth telling the caller apart from “no blocklist
configured.”
required: a required parameter
#[hooks]
pub struct StateForeign {
/// The target account whose flag this hook reads (`ACCT`).
#[hook_param(name = b"ACCT", required)]
acct: HookParam<AccountId>,
}
let Ok(target) = self.hook_param.acct.get_required() else {
rollback!(
b"state-foreign: ACCT parameter not configured",
StateForeignError::AcctNotConfigured
)
};
(from examples/09_state-foreign’s &self entry.) get_required()
collapses “absent” and
“present but malformed” into the same Err at this call site — the hook
treats both as “can’t proceed,” and the else branch rolls back either
way.
default: a compiled-in fallback
#[hooks]
pub struct TypedData {
/// Install-time configuration (`CFG`). Falls back to compiled-in
/// defaults when absent.
#[hook_param(name = b"CFG", default = Config { min_amount: DEFAULT_MIN_AMOUNT, lock_ledgers: DEFAULT_LOCK_LEDGERS })]
config: HookParam<Config>,
}
(from examples/12_typed-data.) default’s expression is a runtime
fallback, not baked into the deployed SetHook template as an installed
value — see Per-Hook Attributes for why a
HookParameters entry has to be added to the template by hand if you want
a position to install with a concrete value at all. Contrast required
above: the field declaration itself (default = ... vs. required) is
where a parameter’s presence policy lives, not scattered across call
sites — config here degrades gracefully when CFG is unconfigured, the
same way examples/09_state-foreign’s acct field (above) never does.
Composite names: #[derive(ParamName)] and name_by
A Hook API parameter name isn’t always a plain literal tag — per the Hook
API itself it’s a genuine variable-length key of up to 32 bytes, and (like
a hook state key) can be a whole composite, struct-shaped value instead of
a byte string. #[derive(ParamName)] derives ToBytes (write-only — see
Typed Data with Derives) for a named-field struct used
this way; name_by = <TypePath> is how a field references one:
#[derive(ParamName, Clone, Copy)]
struct AdminName {
section: u8,
field: u8,
}
#[hooks]
pub struct TypedData {
/// Administrative deposit pause switch, addressed by [`AdminName`].
/// Falls back to "not paused" when absent.
#[hook_param(name_by = AdminName, default = PauseSwitch { paused: 0 })]
admin_pause: HookParam<PauseSwitch>,
}
Unlike a key = .../name = ... field, which is ready to read directly, a
name_by field needs its name’s runtime value bound first, via .at(...)
— the same shape Hook State
uses for a keyed state entry:
const ADMIN_PAUSE_NAME: AdminName = AdminName { section: 0, field: 0 };
fn deposits_paused() -> Result<bool> {
TypedData
.hook_param
.admin_pause
.at(ADMIN_PAUSE_NAME)
.get_or_default()
.map(|s| s.paused != 0)
}
Returning Result<bool> rather than bool keeps a malformed switch’s
Err visible to the caller, which rolls back on it instead of treating it
as false.
.at(args) returns a handle over that one bound name, carrying the same
.get()/.get_or_default()/.get_required() accessors the base field
has (gated by the same required/default declaration). AdminName
encodes to 2 bytes (section then field, no padding), comfortably
inside the Hook API’s 1-to-32-byte parameter-name bound. Unlike an
oversized HookData state value (no size cap at all), #[derive(ParamName)]
checks this bound — both the 1-byte lower bound and the 32-byte upper
bound — at the struct’s own definition, so an out-of-range name fails
to compile before it’s ever used.
Because AdminName is composite rather than a fixed byte string, its
name-encoding has to actually run at runtime — laying section and
field out into a small buffer sized exactly to AdminName::MAX_LEN.
Measured on examples/12_typed-data: a small, fixed number of worst-case
instructions over the same hook without the composite name, versus the
near-zero cost of the plain CFG tag used elsewhere in that same hook.
Signature parameters (fn arguments)
This section’s surface requires the
unstable-param-sig-interfacefeature:rshooks = { version = "…", features = ["unstable-param-sig-interface"] }.unstable-*features track draft specs and are exempt from semver — breaking changes may land in a minor release while the spec is a draft.
Both surfaces above pair a name with a value type through this crate’s own
choices (a struct field, default/required). The Hook Parameter
Signature Interface is a different, protocol-level convention: a
HookParameterName wire format that makes a Hook’s declared parameters a
machine-readable, typed function signature, and rshooks maps it onto the
most direct possible Rust surface — extra arguments on the entry fn itself.
The normative rules live in docs/PARAM_SIGNATURE_DESIGN.md; this section
covers the day-to-day surface built on it.
#[hooks]
impl Increment {
/// increment(account: AccountID, count: UInt16)
#[hook(0, on = [Invoke])]
fn increment(&self, account: AccountId, count: u16) -> HookResult {
// `account` and `count` are already decoded here.
...
}
}
(from examples/19_param-signature, the interface draft’s own worked
example.) Every argument after &self on a #[hook(..)] fn declares one
signature parameter, in declaration order — the argument’s position (0-based)
is its wire index, its identifier is its display name, and its type
picks the wire type byte. #[cbak(..)] fns cannot declare extra
arguments: a callback’s originating transaction is the emitted transaction,
not the invocation, so the interface doesn’t apply there.
The wire name
Each declared parameter’s HookParameterName is a fixed-layout byte string,
8 to 23 bytes total:
| bytes | meaning |
|---|---|
0x5F 0x50 0x53 | "_PS" interface identifier |
0x00 | version |
| 1 byte | index, 0x00..=0x0F (so at most 16 arguments per entry) |
| 1 byte | the type code (see the type table below) |
| 1 byte | name length, 0x01..=0x10 |
| 1 to 16 bytes | the display name, [A-Za-z][A-Za-z0-9]* (no _) |
rshooks builds this name entirely at macro/compile time — never at
runtime — and validates every one of those rules (index range, a supported
type byte, the name’s charset/length) as a const-evaluable assert, so a
malformed declaration is a compile error, not a deploy-time or runtime
surprise. A Rust identifier containing _ (the common case — min_amount,
say) therefore cannot be used as a signature-parameter argument name; rename
it, or fall back to the escape hatch below with an explicit name literal.
Supported types
| Rust type | type code | wire payload |
|---|---|---|
u8 | 0x10 (STI_UINT8) | 1 byte |
u16 | 0x01 (STI_UINT16) | 2 bytes |
u32 | 0x02 (STI_UINT32) | 4 bytes |
u64 | 0x03 (STI_UINT64) | 8 bytes |
[u8; 16] | 0x04 (STI_UINT128) | 16 bytes |
[u8; 32] / Hash | 0x05 (STI_UINT256) | 32 bytes |
AmountBytes | 0x06 (STI_AMOUNT) | 8 (native) or 48 (IOU) bytes |
Blob<N> | 0x07 (STI_VL) | 1 to min(N, 256) bytes |
AccountId | 0x08 (STI_ACCOUNT) | 20 bytes |
[u8; 20] | 0x11 (STI_UINT160) | 20 bytes |
IssueBytes | 0x18 (STI_ISSUE) | 20 (native, all-zero) or 40 (issued) bytes |
CurrencyCode | 0x1A (STI_CURRENCY) | 20 bytes |
XFL | 0x80 (XFL, XAS-010d non-standard) | 8 bytes |
Every integer type here decodes big-endian, unlike this crate’s own
ToBytes/FromBytes little-endian convention covered earlier on this page
and in Typed Data with Derives — a signature parameter’s
value crosses the same protocol boundary a raw otxn_field/otxn_param
read does (see Reading the Originating Transaction), not this
crate’s own hook-private wire format. Blob<N>/IssueBytes are new types
in rshooks::sig; every other row is a type this page and Typed Data with
Derives already cover. XFL’s payload is the Hook API’s
int64_t XFL bit pattern, big-endian — XFL::raw_bits() — decoded via
XFL::from_raw_bits with no validity check; the type codes themselves come
from XAS-010d (Hook Type Codes), which both this interface and the Hook
State Interface reference for their type codes.
The generated prologue and its rollback
For each declared argument, the #[hooks]-generated code ahead of the
entry’s body reads the value via otxn_param (against the full declared
name above) and decodes it per the argument’s type. On any failure —
absent, or the wrong length/shape for the declared type — it rolls back
immediately, with:
- message
b"rshooks: bad sig param '<name>'" - code = the argument’s own 0-based index
before the body ever runs, so the body never sees a partially-decoded
invocation. See examples/19_param-signature for this rollback exercised
end-to-end (both a hand-written unit test via
rshooks_testenv::TestEnv::invoke, and e2e).
The >= 16 convention for hook-authored rollback codes
Because the generated prologue’s own rollback code is always an argument
index, 0x00..=0x0F (0..=15 — the interface’s own index bound), any
rollback!/hook_errors! code your own entry body uses has to stay clear
of that whole range, or the two rollback sources (the generated prologue,
and your own body) become ambiguous by code alone: a caller inspecting
HookReturnCode in isolation can no longer tell “argument 3 was malformed”
from “my own error variant 3” without also checking the message. Every
rshooks example that declares signature parameters and its own
hook_errors! enum (currently just examples/19_param-signature) follows
the same fix: number every hook-authored variant from 16, one
past the highest possible argument index, rather than the usual 1. This
is a convention, not something the macro enforces — hook_errors! accepts
any i64 discriminant — but it is the one this crate’s own examples use
consistently for any signature-parameter-declaring entry, and is worth
adopting in your own hooks for the same reason.
The escape hatch
Per the standing rule that every macro surface documents its raw
counterpart, rshooks::sig exposes the same name-building and decoding
directly, for a hand-rolled read outside the entry-fn-argument surface:
use rshooks::sig::otxn_sig_param;
use rshooks::sig_name;
const COUNT_NAME: [u8; 12] = sig_name!(1, u16, b"count");
let count: rshooks::error::Result<u16> = otxn_sig_param(&COUNT_NAME);
sig_name!(index, Type, name) resolves the wire name and type byte for
you at compile time; sig_param_name (also in rshooks::sig) is its
lower-level, non-macro counterpart. See crates/rshooks/src/sig.rs’s own
rustdoc for the full trait (SigParamType) and every type’s decode
contract.
Generated SetHook declarations
An entry with signature-parameter arguments needs its parameters
declared, not just read — the interface requires an on-ledger
declaration entry (HookParameterValue = 0x00) at SetHook time for every
parameter the entry’s signature names. rshooks build generates this
automatically: any #[hook(..)] entry with signature-parameter arguments
gets a HookParameters block in sethook.template.json, one entry per
declared argument, in index order — see Per-Hook Attributes and the
SetHook Template for the exact shape and where it
sits among that entry’s other generated fields. This supersedes the
general “HookParameters is never generated” rule only for declared
signature parameters; an ordinary #[hook_param(...)]/#[otxn_param(...)]
field (covered earlier on this page) still never appears there.
Why this prevents name/value mismatches
The loose hook_param_exact::<T>(name)/otxn_param_exact::<T>(name) calls
take name and T as two independent arguments — a typo or a copy-paste error
can pair the right name with the wrong type, or the wrong name with the right
type, and both compile fine as long as T: FixedRead. A
#[hook_param(...)]/#[otxn_param(...)] field removes that degree of freedom:
the field’s declared name is permanently tied to exactly one value type, so
TypedData.hook_param.config.get_or_default() (read from a free function
outside the impl) and self.hook_param.acct.get_required() (read directly
inside examples/09_state-foreign’s &self entry) can never accidentally
decode one parameter’s bytes as the other’s struct shape — the compiler
resolves the return type from the field itself, with no
independently-chosen type left for a mismatch to hide in. This is the
identical safety property Hook State’s #[state(...)] fields
give the key/value side; see Typed Data with Derives for
the underlying ParamName/ParamValue derives both build on, and Hook
Chains for how a field declared once here is
shared across every Hook entry in the same chain.
Typed Data with Derives
Hook state values, hook-state keys, and Hook API parameter names/values all
share the same underlying shape: a fixed-size, named-field Rust struct that
needs to cross the boundary into and out of a protocol byte buffer. This
page covers the conversion traits that shape rests on (ToBytes/
FromBytes/FixedRead), the four derive macros that generate them for
your own structs, and why those derives cost nothing over hand-packing the
bytes yourself. Hook State and Hook and Transaction
Parameters both build directly on what’s covered here.
The conversion traits
Two small traits fix exactly how a fixed-size value crosses the boundary:
pub trait ToBytes {
const MAX_LEN: usize;
fn write(&self, buf: &mut [u8]) -> usize;
}
pub trait FromBytes: Sized {
fn read(buf: &[u8]) -> Result<Self>;
}
ToBytes::write encodes self into the front of buf, returning
Self::MAX_LEN on success or 0 if buf is too short — never a partial
write. FromBytes::read decodes Self from buf, failing with
HookError::TooSmall if buf is shorter than expected. Every primitive
this crate cares about implements both: u8/u16/u32/u64/i64,
XFL, [u8; N] for any N, and every rshooks::types newtype
(AccountId, Hash, CurrencyCode, …).
A third trait, FixedRead, backs the *_exact family covered in Reading
the Originating Transaction and Hook State — it reads
a value in one shot from a caller-buffer host call (otxn_field,
hook_param, state, slot), by allocating exactly its own fixed-size
buffer and requiring the read to fill it exactly.
Every field’s byte layout follows one crate-wide convention: little-endian,
back-to-back, in declaration order. This is deliberately the opposite
convention from the originating transaction’s own wire fields, which are
big-endian Xahau Binary — see Reading the Originating
Transaction’s “Decoding a raw field” section for the full
two-world rule. ToBytes/FromBytes are for hook-private data: state and
parameter values this crate’s own typed layer wrote, never protocol fields
read directly off the transaction.
Four derives, four narrow roles
A struct used as a state key, a state value, a parameter name, or a
parameter value all share the same “fixed-offset, named-field struct”
shape — but they play genuinely different roles, so rshooks keeps them as
four separate, narrower derives rather than one derive covering everything:
| 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 composite key/value and name/value structs with the
derives this page covers, then wires each into a #[hooks] struct field —
covered in full in Hook State and Hook and Transaction
Parameters:
// Per-account deposit record key: a tag byte + AccountId.
#[derive(HookKey, Clone, Copy)]
struct DepositKey {
tag: u8,
owner: AccountId,
}
// Per-account deposit record value.
#[derive(HookData, Clone, Copy)]
struct DepositValue {
amount: u64,
deadline: u32,
flags: u8,
}
// Install-time configuration, read from the `CFG` Hook parameter.
#[derive(ParamValue)]
struct Config {
min_amount: u64,
lock_ledgers: u32,
}
// Per-invocation instruction, read from the `INS` originating-transaction
// parameter.
#[derive(ParamValue)]
struct Instruction {
action: u8,
amount: u64,
}
#[hooks]
pub struct TypedData {
/// Per-account deposit record, keyed by [`DepositKey`].
#[state(key_by = DepositKey)]
deposits: State<DepositValue>,
/// Install-time configuration (`CFG`).
#[hook_param(name = b"CFG", default = Config { min_amount: DEFAULT_MIN_AMOUNT, lock_ledgers: DEFAULT_LOCK_LEDGERS })]
config: HookParam<Config>,
/// Per-invocation instruction (`INS`). Missing or malformed is a
/// rollback, never a silent default.
#[otxn_param(name = b"INS", required)]
instruction: OtxnParam<Instruction>,
}
DepositKey gets HookKey-equivalent codegen; DepositValue/Config/
Instruction get HookData/ParamValue-equivalent codegen — the field
attributes (#[state(key_by = ...)], #[hook_param(...)],
#[otxn_param(...)]) tie each field’s key/name to its value type, the
struct-field equivalent of HookState’s pairing form. Used directly inside
the #[hooks] impl via a &self entry, with no manual byte packing
anywhere:
let deposit = self.state.deposits.at(DepositKey { tag: DEPOSIT_TAG, owner });
let current = deposit.get()?.unwrap_or(EMPTY_DEPOSIT);
// ...
deposit.set(&next)?;
What the derives replace
Without them, DepositKey/DepositValue would need hand-written encode/
decode functions — every field’s offset counted by hand, every reader kept
in sync with every writer by hand:
// Key: tag (1 byte) || owner (20 bytes) — 21 bytes total, sent to the
// host exactly as-is (the host itself left-pads a key shorter than its
// fixed 32-byte storage width — see "Key length and padding" in Hook
// State — no local zero-padding here).
fn make_key(owner: &AccountId) -> [u8; 21] {
let mut out = [0u8; 21];
if let Some(b) = out.get_mut(0) {
*b = DEPOSIT_TAG;
}
if let Some(dst) = out.get_mut(1..21) {
dst.copy_from_slice(owner.as_ref());
}
out
}
// Value: amount (8 bytes LE) || deadline (4 bytes LE) || flags (1 byte).
fn encode_value(v: &DepositValue) -> [u8; 13] {
let mut out = [0u8; 13];
if let Some(dst) = out.get_mut(0..8) {
dst.copy_from_slice(&v.amount.to_le_bytes());
}
if let Some(dst) = out.get_mut(8..12) {
dst.copy_from_slice(&v.deadline.to_le_bytes());
}
if let Some(b) = out.get_mut(12) {
*b = v.flags;
}
out
}
#[derive(HookData)] generates the equivalent of this — the same fixed,
compile-time offsets, the same .get_mut()-guarded fixed-size copies —
once, from the struct definition itself, and keeps ToBytes/FromBytes
in sync automatically as fields are added, removed, or reordered.
The zero-cost claim: measured, not assumed
Every field offset in a derived struct is a compile-time constant, and
every field read/write delegates straight to that field’s own
ToBytes::write/FromBytes::read — no per-field loop, and (for a total
size this toolchain’s release profile still lowers to inlined stores
rather than a memset/memcpy builtin call) no unguarded loop at all.
examples/12_typed-data backs this with a real rshooks build/check
measurement: this hook’s core deposit-ledger logic, built twice — once
with the derives as committed (the numbers in its metrics.json), once
with all four hand-packed instead, everything else byte-for-byte
identical.
The derived version isn’t just as cheap — it measures cheaper in both
worst-case instructions and wasm size: the
generated write/read check the struct’s total length once
(buf.get_mut(..Self::MAX_LEN)), then copy every field through
already-proven-in-bounds fixed offsets, whereas naive hand-packing
re-checks bounds with a separate .get()/.get_mut() call per field. A
hand-written version that front-loads one length check the same way could
match the derive’s number — the point is the derive always generates
that shape, by construction, without a hook author having to discover and
apply the trick themselves. Both versions are guard-clean at the source
level; neither needs a hand-written guard.
Composite parameter names have one caveat: unlike a plain byte-string tag
(CFG/INS above, free — the wire encoding is the in-memory bytes,
handed to the host with no copy), a struct-shaped name like AdminName in
Hook and Transaction Parameters has to actually run its
write() at runtime, since Rust has no stable way to run a trait method at
compile time. That’s still measured cheap — a small, fixed number of
worst-case instructions in that example — see that page’s “Composite
names” section for why it can’t go to zero.
What each derive rejects at compile time
All four share the same field grammar: a plain, non-generic, named-field struct with at least one field, every field a fixed-size type implementing the traits that derive needs. An enum, a tuple struct, or a unit struct is rejected:
use rshooks::HookData;
#[derive(HookData)]
enum NotAStruct {
A,
B,
}
A field of a variable-length type (a bare slice, a Vec, …) fails with
rustc’s own trait-bound error against the generated impls, naming the
missing trait — the derive doesn’t implement its own type checker. And a
HookData value struct doesn’t automatically work as a key:
use rshooks::HookData;
use rshooks::prelude::*;
#[derive(HookData)]
struct NotAKey {
a: [u8; 20],
}
// ERROR: `NotAKey` has no `StateKeyEncode` impl — use `HookKey` for a key.
let _ = state_get::<u64>(&NotAKey { a: [0; 20] });
For the complete grammar, every generated item, and the full set of
compile_fail examples pinning each misuse, see the HookKey/HookData/
ParamName/ParamValue derives’ own rustdoc — this page summarizes the
parts most relevant to everyday hook code.
XFL: Decimal Floating Point
Xahau amounts and rates are not f64. They are XFL, a 64-bit decimal
floating-point format the Hook API itself defines: a sign bit, an 8-bit
biased exponent, and a 54-bit mantissa normalized to exactly 16 significant
decimal digits. Every arithmetic operation on an XFL value is a host call —
rshooks never computes on the bit pattern itself — because only the host
knows how to normalize a result and detect overflow the same way the rest of
the ledger does. This page covers the XFL type, how to construct values
(including at compile time), the checked operators and comparison methods,
reading an Amount field as XFL, and XFLUnchecked for hot paths that can
defer validation to the end of a chain.
Why not f64
f64 cannot represent most decimal fractions exactly — 0.1 in binary
floating point is already an approximation. XFL exists precisely to avoid
that: every decimal value expressible in 16 significant digits has exactly
one correct XFL encoding, bit-for-bit. Any bridge from decimal text to XFL
that routes through f64 reintroduces the very rounding error XFL is
designed to eliminate — which is why the XFL! literal macro below never
touches f64 at all.
The XFL type
rshooks::xfl::XFL wraps a raw XFL bit pattern. The inner value is private:
a host XFL call can return a negative value down the same i64 channel used
for float results, so a public field would let an error code masquerade as a
value. Two explicit escape hatches cross that boundary:
use rshooks::xfl::XFL;
let one = XFL::one();
let bits = one.raw_bits();
let same = XFL::from_raw_bits(bits);
assert_eq!(same.raw_bits(), bits);
XFL::one() and XFL::new(exponent, mantissa) never need f64-style
rounding, but only XFL::one() is guaranteed not to fail — it’s the fixed
bit pattern for 1.0, a const fn, no host call. XFL::new builds a
normalized value from a runtime-computed exponent/mantissa pair, via the
float_set host call:
let min_share = XFL::new(exponent, mantissa)?;
XFL’s mantissa is always normalized to 16 significant digits (10^15 to
10^16 - 1), so 0.000001 is not written as exponent -6 with mantissa
1 — it has to be mantissa 1_000_000_000_000_000 (1e15) with exponent
-21, since 1e15 * 10^-21 == 10^-6. Getting the mantissa/exponent split
wrong is an easy mistake, and XFL::new returning Result rather than
silently normalizing is what catches it — but for a fixed constant known at
compile time, like 0.000001 itself, XFL! below does that split for you
and needs no Result at all; keep XFL::new for a pair actually computed
at runtime.
The XFL! compile-time literal macro
For a fixed constant, hand-computing that mantissa/exponent split — or the
raw bit pattern — is exactly the kind of arithmetic a macro should do
instead. XFL! takes a decimal literal and expands, at compile time, to
XFL::from_raw_bits(<bits>i64):
use rshooks::XFL;
use rshooks::xfl::XFL as XflType;
const DEFAULT_REWARD_RATE: XflType = XFL!(0.003333333333333333);
assert_eq!(DEFAULT_REWARD_RATE.raw_bits(), 6_038_156_834_009_797_973);
Because the expansion is XFL::from_raw_bits, a const fn, the result works
directly in const/static position:
use rshooks::XFL;
use rshooks::xfl::XFL as XflType;
const ONE: XflType = XFL!(1);
static REWARD_DELAY: XflType = XFL!(2600000);
assert_eq!(ONE.raw_bits(), 6_089_866_696_204_910_592);
assert_eq!(REWARD_DELAY.raw_bits(), 6_199_553_087_261_802_496);
The macro parses the literal’s text by hand — integer arithmetic on the
digit string — rather than going through f64, which is the whole point:
bit-exactness for every representable decimal, not an approximation.
Grammar. An optional leading -, then exactly one numeric literal
token: a plain integer (123456789, optionally with _ separators like
1_000_000), a decimal (0.1, 1., 1.50), or either with a decimal
exponent (1e-5, 2.6E6, 1e+3). Trailing zeros are normalized away, so
1.50, 1_000, and 2600000 encode exactly as if written 1.5, 1e3,
and 2.6e6.
What gets rejected, always as a compile_error!, never a panic:
- anything that is not a single numeric literal token — a string/char/byte
literal, a hex/octal/binary integer (
0x../0o../0b..), missing input, or extra tokens - a numeric type suffix (
1i64,1.0f64) —XFL!always produces its 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.
Add/Mul/Div/Neg each issue one host call: self + rhs is
float_sum, self * rhs is float_multiply, and so on, and Neg is a
real float_negate round trip, never a local sign-bit flip. Sub is
self + rhs.negated(): also one host call (float_sum), since negating
the right-hand side is a local sign-bit flip (XFL::negated), not the
Neg operator’s host round trip — there is no dedicated float_subtract
host function either way.
let remaining = match amount - share {
Ok(x) => x,
Err(_) => rollback!(b"xfl-math: amount - share failed", ...),
};
(from examples/07_xfl-math). Because every operator’s Output is a
Result, rshooks also implements the mixed combinations
Result<XFL> op XFL and XFL op Result<XFL>, so a chain that alternates a
plain XFL in on each side short-circuits on the first error without an
explicit ? between every step. (Rust’s orphan rules forbid Result on
both sides of one of these impls, so an independently-fallible value on
each side still needs a ? first.)
mulratio(round_up, num, den) computes self * (num / den) in one host
call — used for percentage-style scaling:
let share = amount.mulratio(false, 1, 100)?; // 1% of `amount`, rounding down
It takes two extra scale parameters beyond a plain rhs, so it stays a
named method rather than trying to fit an operator shape.
Comparison: methods and operators
.eq(rhs), .lt(rhs), .gt(rhs), and .compare(rhs, mode) all return
Result<bool>, backed by the fallible float_compare host call.
.compare() takes a bitmask (COMPARE_EQUAL, COMPARE_LESS,
COMPARE_GREATER, freely combined — e.g. COMPARE_LESS | COMPARE_EQUAL for
<=, since there’s no dedicated le/ge method).
XFL also implements PartialEq/PartialOrd (==, <, >, …),
forwarding to those same methods — but these traits have a fixed
bool/Option<Ordering> return type with no room for an Err, so on a
float_compare failure they fall back to false/None, the same
convention f64 uses for NaN. That is the wrong choice whenever a
comparison gates a rollback decision on an operand that hasn’t been
separately validated — silently treating “the comparison failed” the same
as “not below the minimum” would mean accepting a transaction the hook
never actually validated. Prefer the Result-returning methods, matched
three ways, for exactly those cases:
match share.lt(min_share) {
Ok(true) => rollback!(b"xfl-math: computed share below minimum", ...),
Ok(false) => {}
Err(_) => rollback!(b"xfl-math: comparison failed", ...),
}
The operators are reasonable specifically when both operands are already
host-validated XFL values with no realistic path to a float_compare
failure — a pure sanity check where “incomparable” and “not greater than”
deserve identical handling:
if compounded > remaining {
rollback!(b"xfl-math: compounded share unexpectedly exceeds remaining amount", ...);
}
(compounded and remaining here only exist because two earlier Results
already returned Ok — see examples/07_xfl-math’s README for the full
reasoning on when each style applies.)
Reading an Amount field as XFL
The typed slot layer (see Slots and Ledger Objects) reads an
Amount field as XFL directly, working identically whether the amount is
native (XRP/XAH) or an IOU:
let txn = SlotObject::from_otxn()?;
let amount: XFL = txn.get(sfAmount)?.as_xfl()?;
SlotObject<Amount>::as_xfl is a direct slot_float call. For a native
amount, the result comes back in XAH units, not drops — the host builds
it from the drop count as mantissa with exponent -6, then normalizes.
Recover the drop count with xfl.to_int(6, false).
An equivalent route using the raw numbered slot API, reading the same field without the typed layer:
let txn_slot = otxn_slot(0)?;
let amount_slot = slot_subfield(txn_slot, sfAmount, 0)?;
let amount = XFL::from_slot(amount_slot)?;
Both call the same host function under the hood; the typed form just needs
no slot numbers. XFL::sto/XFL::sto_set are the corresponding
encode/decode pair for a serialized Amount buffer that isn’t already in a
slot (e.g. building a transaction’s own Amount field by hand).
When the amount already came back as an AmountBytes::Iou — from
otxn_field_typed(sfAmount) or a views::tx accessor (see Typed
Views) — IouAmount::xfl() decodes its value straight from those
bytes, with no slot at all:
let AmountBytes::Iou(iou) = otxn_field_typed(sfAmount)? else {
accept!(); // native, not handled here
};
let value: XFL = iou.xfl()?;
It hands the host exactly the amount’s 8-byte value component via
XFL::sto_set, never the full 48 bytes and never a local bit-reinterpret —
the wire value component sets an always-on “not native” flag bit a real XFL
never sets, so either shortcut would produce a wrong result.
XFLUnchecked for hot paths
rshooks::xfl_unchecked::XFLUnchecked is the deferred-validation
counterpart to XFL: every operator is still a real host round trip — there
is no local fast path — but with no guest-side Result branch between
steps. A poisoned or invalid operand propagates through the host calls (an
invalid input is rejected by the host and the result is INVALID_FLOAT’s
own raw bits, itself a valid poison value to keep propagating), and a single
.validate() call at the end turns the final raw value into a real
Result<XFL, HookError>:
let compounded_raw =
share.unchecked() * growth.unchecked() * growth.unchecked() * growth.unchecked();
let compounded = match compounded_raw.validate() {
Ok(x) => x,
Err(_) => rollback!(b"xfl-math: compounded share failed to validate", ...),
};
(from examples/07_xfl-math.) XFL::unchecked() is a zero-cost
reinterpretation into XFLUnchecked — no host call — and the two types mix
freely at a chain’s boundary (XFLUnchecked op XFL and XFL op XFLUnchecked are both implemented, treating the XFL side as implicitly
unchecked), so the usual shape is: start from a known-valid XFL, run the
hot loop in XFLUnchecked, validate once at the end.
validate() is implemented as float_sum(self, 0) — a real host round
trip, not a guest-side range check — specifically so it reuses the host’s
own validation gate instead of re-deriving XFL’s mantissa/exponent rules
locally and risking drift.
This is only worth reaching for on a measured hot path. Benchmarked against
a checked Result-chain of the same operations, XFLUnchecked’s marginal
cost per chained multiply matches a hand-written raw host-call chain
exactly (+3 instructions/op vs. the checked chain’s +14); for Sub
(two host calls per step, since Neg isn’t free), it’s +5 vs. +27. The
win is entirely about when validation happens — once, at the end, instead
of once per step — never about skipping any host validation a correct
implementation actually needs. Use XFL’s checked operators by default,
and reach for XFLUnchecked only once a chain like this is the measured
bottleneck.
Slots and Ledger Objects
The Hook API’s slot machine is a set of 255 numbered registers a hook
can load deserialized ledger objects and transactions into, then navigate
into their fields and array elements. rshooks gives you two ways to work
with it: a raw layer that mirrors the host API one function per call, and a
typed layer (SlotObject<T>) that replaces slot-number bookkeeping with
Rust types. This page covers the typed layer in depth, measures it against
the raw one, and explains why the raw numbered functions are deliberately
kept out of the prelude.
The slot machine, briefly
A slot holds one deserialized object — a transaction, a ledger entry, or a
field/array-element derived from one already loaded. Slots are numbered
1..=255; passing 0 as a target slot number asks the host to
auto-assign one. Every slot a hook populates is freed automatically when
the hook returns, so a short-lived hook that reads a handful of fields once
never needs to think about cleanup at all — the cost model only starts to
matter once a loop derives more slots than the 255-slot budget allows.
The typed layer: SlotObject<T>
rshooks::slot_obj::SlotObject<T> is a handle to one loaded slot, typed by
what it holds. Slot numbers are auto-assigned by the host and never appear
in hook source:
let account = SlotObject::from_keylet(&keylet_account(accid)?)?;
let seq: u32 = account.get(sfSequence)?.value()?;
let bal: XFL = account.get(sfBalance)?.as_xfl()?;
Four constructors load a root slot:
SlotObject::from_otxn()— the originating 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
Once the loaded object’s ledger-entry or transaction type is known,
rshooks::views::ledger/rshooks::views::tx give named field accessors
built on top of exactly this constructor set (RippleState::from_keylet,
Payment::from_slot, …) instead of a .get(sfXxx) call per field — see
Typed Views.
.get(sfXxx) and subfield navigation
.get(key) derives a child slot and borrows the parent, so one loaded
object can yield several children without reloading it:
let signers = SlotObject::from_keylet(&keylet_signers(accid)?)?;
let entries = signers.get(sfSignerEntries)?; // SlotObject<STArray>
let first = entries.get(0u32)?; // SlotObject<STObject>
let who: AccountId = first.get(sfAccount)?.value()?;
The key decides both the navigation and the resulting type: an SField<T>
constant (sfAccount, sfBalance, …) navigates a field and yields
SlotObject<T>; a u32 index navigates an array element and yields
SlotObject<STObject>. This is checked at compile time —
SlotObject<STObject>::get(0) (indexing an object) and
SlotObject<STArray>::get(sfAccount) (field-navigating an array) are both
compile errors, not runtime surprises.
.value()
Once you’ve navigated to a leaf field, .value() reads it out, consuming
the handle:
let dest_slot = txn.get(sfDestination)?;
let dest: AccountId = dest_slot.value()?;
value() is generated for the scalar and fixed-size types this layer
understands — u8/u16/u32/u64, AccountId, Hash, CurrencyCode —
plus the two amount-shaped types below. No turbofish is needed: the
SField<T> (or the earlier navigation) already fixed T.
AmountBytes, IssueData, and CastTarget
SlotObject<Amount> and SlotObject<Issue> classify their contents by
serialized length rather than assuming a shape:
pub enum AmountBytes {
Native(NativeAmount), // 8 bytes
Iou(IouAmount), // 48 bytes
}
pub enum IssueData {
Native, // 20 bytes
Iou(IssuedAsset), // 40 bytes: currency and issuer
}
pub struct IssuedAsset {
pub currency: CurrencyCode,
pub issuer: AccountId,
}
SlotObject<Amount>::value()/take_value() return AmountBytes;
SlotObject<Issue>::value()/take_value() return IssueData. Both
reject an MPT-length encoding as HookError::ParseError rather than
guessing — MPT amounts are out of scope for this layer since Xahau has no
amendment for them yet. SlotObject<Amount>::as_xfl() (see
XFL) is the more common route when what you actually want is the
numeric value rather than the raw bytes, and it works identically for
native and IOU amounts.
IouAmount itself gives back its (currency, issuer) identity without a
separate decode step: .currency(), .issuer(), and .asset() (the pair,
as an IssuedAsset) all borrow the wire bytes in place rather than parsing
them. .matches_asset(&asset) compares an amount’s currency/issuer against
an already-known IssuedAsset the same way — via buf_eq_20, never a
memcmp loop — without constructing an intermediate IssuedAsset to do it:
let AmountBytes::Iou(iou) = payment.amount()? else {
accept!(); // native, out of scope for this check
};
let asset = iou.asset(); // IssuedAsset { currency, issuer }
if iou.matches_asset(&expected) { /* ... */ }
try_cast::<U>() retypes a handle after checking the slot’s serialized
type ID against U’s CastTarget implementation — STObject, STArray,
Amount, Issue, u8/u16/u32/u64, Hash, AccountId,
CurrencyCode all implement it. Any failure (a mismatch or an underlying
host error) consumes the handle and best-effort clears the slot.
assume_type::<U>() is the free, unchecked twin, for when the caller
already knows the slot’s contents from context the type system can’t see.
slot_path! for multi-hop navigation
A chain of .get(a)?.get(b)?.get(c)? leaks every intermediate slot — each
temporary handle is dropped without clearing, and nothing clears
automatically on drop. slot_path! auto-assigns a slot for the first hop,
then rewrites that same slot number in place for every later hop — the host
skips the storage copy when the requested slot equals the parent slot — so a
10-hop path costs one slot, not ten, and clears nothing on the success path:
use rshooks::slot_path;
let signers = SlotObject::from_keylet(&keylet_signers(accid)?)?;
let first: AccountId = slot_path!(signers[sfSignerEntries][0u32][sfAccount])?.value()?;
The root is borrowed and never cleared (it’s the caller’s handle, evaluated once). A hop after the first that fails clears the ladder’s one slot before returning the error, so a failed lookup cannot leak the parent that produced it either.
Recycling with take_*
.value()/.as_xfl()/.raw()/.raw_exact() all consume the handle
without clearing the slot — deliberately: this matches the C cost model
exactly (a C slot_subfield followed by a slot() read leaks the slot
identically), and an implicit clear would tax every read with an extra host
call the C idiom never pays. For a short hook reading a few fields once,
that’s the right tradeoff — the host frees every slot when the hook
returns regardless.
A loop deriving one child slot per iteration is the case that actually
needs to give slots back mid-execution: 255 is the whole per-execution
budget, so a 300-iteration loop that derives a slot each time will run out.
take_value()/take_xfl()/take_raw_exact() read and clear, on both
the success path and the failure path:
let mut ok: u32 = 0;
let mut i: u32 = 0;
while i < LOOP_ITERATIONS {
guard!(LOOP_ITERATIONS);
i = i.wrapping_add(1);
if let Ok(leaf) = slot_path!(root[sfSignerEntries][0u32][sfAccount]) {
if leaf.take_value().map(|_: AccountId| true).unwrap_or(false) {
ok = ok.wrapping_add(1);
}
}
}
examples/15_slot-objects proves this live: a 256-iteration loop of plain
.get() + .value() calls (over the 255-slot budget) would exhaust the
budget partway through, but the same loop through take_value() completes
all 256 iterations — including a separate 256-iteration loop of failing
take_value() calls, proving the clear happens on the failure path too, and
one of failing try_casts, proving the same for cast failures.
Measured: typed vs. raw
examples/08_slot-ledger rewrote a raw numbered-slot walk
(otxn_slot → slot_subfield → slot_exact) into the typed
equivalent and built both at this workspace’s opt-level = 3, with and
without clearing the slots afterwards.
Without clears — the apples-to-apples comparison, same host calls, same
cleanup policy — the two builds are byte-identical: every typed wrapper is
#[inline(always)] over the same host call, so the type layer adds nothing.
With clears the two aren’t directly comparable: take_* clears on the
failure path as well as success, while the raw code’s slot_clear calls
only run after a successful read, so the typed build’s few extra
instructions buy strictly stronger cleanup rather than being layer
overhead. The committed build’s numbers are in
examples/08_slot-ledger/metrics.json.
Why the raw numbered functions aren’t in the prelude
rshooks::api::slot (slot_set, slot_clear, slot_subfield,
slot_subarray, slot_type, slot_count, slot_size, slot,
meta_slot, and friends) mirrors the host API directly — plain u32 slot
numbers, one function per host call. It addresses the exact same 255
registers SlotObject does. Calling slot_clear(3) while a SlotObject
happens to hold slot 3 corrupts that handle’s meaning: it keeps looking
valid but starts describing whatever the host puts there next. This is a
logic hazard, not a memory-safety one (no unsafe is involved on
either side), so nothing prevents it at the type level — the mitigation is
that these functions are kept out of the prelude and reachable only through
an explicit path (rshooks::api::slot::slot_clear,
rshooks::api::otxn::otxn_slot), so mixing the two layers is at least
always visible at the call site.
Reach for the raw layer only when a hook genuinely wants to place things in
specific numbered slots and manage them itself. Otherwise, default to
SlotObject: it costs nothing extra and the type system catches mistakes
the raw layer can’t.
Keylets
A keylet is a 34-byte locator for a ledger object: a type prefix plus
the 32-byte hash-derived index the protocol uses to find that object in the
ledger’s state map. Almost every ledger read that isn’t the originating
transaction itself starts by computing a keylet, then loading the object it
points at into a slot. This page covers rshooks’s 26 typed keylet_xxx
helpers (each with a keylet_xxx_into out-param twin — see “Why typed
helpers” below), a worked example that computes and stores them, and
account_id!, the companion macro for building compile-time r-address
constants.
Why typed helpers, not one untyped function
The host exposes a single util_keylet function that takes a
keylet_type plus up to six same-typed u32 components (a..f). Which
of those six are used, how many, and what each one means — a raw value
like a sequence number, or a pointer into the hook’s own linear memory for
an account ID or hash — all depend silently on keylet_type. Nothing at
the type level stops passing an account pointer where a sequence number was
expected, and getting it wrong is either a runtime NO_SUCH_KEYLET/
INVALID_ARGUMENT, or worse, a keylet that silently resolves to the wrong
object.
rshooks::api::keylet has one function per KEYLET_* constant instead,
each taking exactly the arguments its own type needs as the real
rshooks::types newtypes — keylet_account takes only an &AccountId,
keylet_line takes two &AccountIds and a &CurrencyCode, keylet_offer
takes an &AccountId and a u32 sequence. Each also has a
keylet_xxx_into(out: &mut Keylet, ...) -> Result<()> out-param twin —
writing straight into caller-supplied storage instead of returning a value
— for a caller about to borrow the result into another buffer-taking call
right away:
let mut keylet = Keylet::default();
keylet_account_into(&mut keylet, &owner)?;
key.with_key_bytes(|k| state_set(keylet.as_ref(), k))?;
The by-value form’s own scratch buffer has its address taken by the host
call, which stops the optimizer from eliding the copy into the caller’s
actual destination on return; writing straight into the caller’s own
buffer has no such intermediate to copy from. The two forms are
independent implementations rather than one delegating to the other —
delegating measurably cost extra worst-case instructions at a call site
that only used the by-value form, so keylet_xxx and keylet_xxx_into
each call the host directly.
The 26 typed helpers
| 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 |
keylet_line_for_asset(account, &asset) is a convenience wrapper over
keylet_line for when the currency/issuer pair is already an IssuedAsset
(the type IouAmount::asset() produces — see Slots and Ledger
Objects) rather than two separate arguments: the trust line
between account and asset.issuer in asset.currency. It has its own
keylet_line_for_asset_into twin, same as the typed helpers above.
Every function returns Result<Keylet>. keylet_hook addresses the
account’s installed hook chain; keylet_hook_definition addresses a
single hook’s own account-independent definition — the two are easy to
conflate but key different objects. Likewise keylet_owner_dir (an
account’s own directory root) is distinct from keylet_page, which
addresses one page of any directory once you already have that
directory’s root index.
keylet_ticket has a known host limitation on the tested xahaud build: the
host’s util_keylet rejects KEYLET_TICKET regardless of ticket_seq,
even though the identical account/sequence shape works through the
ledger_entry RPC and every structurally similar type (keylet_offer,
keylet_escrow, keylet_check, keylet_signers) succeeds. The helper
stays in rshooks — it matches the documented argument shape and a future
host build may support it — but treat it as untested until your target
node confirms otherwise.
A worked example
examples/13_keylets computes 25 of the 26 keylet types (everything but
keylet_ticket, for the reason above) from the invoking transaction’s
sfAccount/sfDestination plus a handful of fixed test inputs, and writes
every 34-byte result into hook state:
let Ok(owner) = otxn_field_typed(sfAccount) else {
rollback!(b"keylets: sfAccount missing from the originating transaction", ...)
};
let Ok(dest) = otxn_field_typed(sfDestination) else {
rollback!(b"keylets: sfDestination missing from the originating transaction", ...)
};
let Ok(keylet) = keylet_account(&owner) else {
rollback!(b"keylets: a keylet_xxx call failed", ...)
};
if KeyletKey::Account
.with_key_bytes(|k| state_set(keylet.as_ref(), k))
.is_err()
{
rollback!(b"keylets: state_set failed", ...);
}
(condensed from examples/13_keylets/src/lib.rs, which repeats this shape
once per keylet type using a small compute/store helper pair). Every
keylet here is computed entirely from inputs already available at compile
time or read directly off the invoking transaction — no other ledger object
has to exist first, so the hook works against a bare node with no setup.
To go from a keylet to the object it addresses, load it into a slot (see Slots and Ledger Objects):
let account = SlotObject::from_keylet(&keylet_account(accid)?)?;
let seq: u32 = account.get(sfSequence)?.value()?;
account_id! for compile-time r-addresses
Several keylet arguments are &AccountId, but a classic Xahau/XRPL
r-address (rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh) is base58-encoded text, not
the raw 20-byte form the Hook API and keylet_xxx want. account_id!
decodes that text entirely at compile time — base58 decode, version-byte
check, and double-SHA256 checksum verification all run inside the proc
macro at cargo build time, never inside the compiled wasm:
use rshooks::prelude::*;
const OWNER: AccountId = account_id!("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh");
Because the expansion is a bare AccountId([..]) literal, OWNER works in
const/static position and the compiled wasm is byte-identical to
hand-writing the 20-byte array yourself — examples/14_account-id-macro’s
e2e suite asserts exactly that against a hand-written control. A malformed
address (bad checksum, wrong length, wrong version byte) is a
compile_error!, not a runtime failure:
// Bad checksum — last character altered.
rshooks::account_id!("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTH");
Reach for account_id! whenever a keylet argument, a hard-coded genesis
account, or any other fixed r-address needs to become an AccountId — it
replaces hand-computing or hex-pasting the 20 bytes yourself.
CurrencyCode::from_iso for 3-character currencies
keylet_line takes a &CurrencyCode. Standard ISO-style codes (USD,
EUR, …) are only 3 ASCII bytes, but the on-ledger encoding is always
20 bytes: twelve zeros, the three characters, five more zeros. A
160-bit non-standard currency still uses the 20-byte tuple constructor;
the 3-character form is from_iso, usable in const/static position:
use rshooks::prelude::*;
const USD: CurrencyCode = CurrencyCode::from_iso(b"USD");
The argument is &[u8; 3], so b"US" or b"USDT" is a type error
rather than a silently-wrong encoding. Native XRP/XAH is a native amount,
not from_iso(b"XRP").
Typed Views
rshooks::views generates one Rust struct per protocol format xahaud
declares — one per transaction type, one per ledger-entry type, one per
inner (nested) object type — each with a named, typed accessor per field.
views::tx::Payment, views::ledger::RippleState,
views::ledger::AccountRoot, and friends replace a run of
otxn_field_typed(sfXxx)/.get(sfXxx)?.value() calls with
payment.destination(), line.flags(), account.balance() — the same
underlying reads (see Reading the Originating Transaction and
Slots and Ledger Objects), with the field list and its value
types taken directly from what upstream declared rather than assembled by
hand at each call site.
Every struct and accessor is generated by cargo xtask gen-core from
xahaud’s own vendored format macros — cargo xtask gen-core --check verifies
the checked-in views are current — so the field lists are upstream’s, not
this library’s opinion of them. The generator’s own hand-written runtime
(rshooks::views::source) holds the one place every view’s absence/slot
policy is decided; the generated files themselves are declarations only.
Two sources, one accessor shape
A transaction or ledger-entry view is generic over where its fields come from:
OtxnSource— reads go straight to the originating transaction (otxn_field), one host call per access. The cheapest source, and the only one a transaction view built withXxx::otxn()uses.SlotSource— wraps an already-loadedSlotObject<STObject>. Reads navigate to a child slot and read it — the only source that can reach into a container, and the only one a ledger-entry view has, since a ledger object is never the originating transaction.
Both are monomorphized and every accessor is #[inline(always)], so the
choice of source compiles away: a view accessor is exactly the host call it
wraps, nothing more.
Constructing a view
Three ways in, depending on where the object already lives and what kind of format it is:
use rshooks::views::{ledger, tx};
// A transaction view, off the originating transaction directly.
let payment = tx::Payment::otxn()?;
// A ledger-entry view, off a keylet.
let line = ledger::RippleState::from_keylet(&keylet)?;
// Either kind, off a slot already loaded some other way.
let payment = tx::Payment::from_slot(some_slot_object)?;
otxn() and from_slot() both check the object’s own type field before
handing back a view — sfTransactionType for a transaction, checked
against a raw tt* code; sfLedgerEntryType for a ledger entry, checked
against a raw lt* code — so a keylet collision, a caller’s wrong
assumption, or the wrong branch of an enum-shaped result surfaces as
HookError::DoesNotMatch rather than as a field read that silently returns
another object’s bytes. from_keylet does the same check after loading the
slot itself. On any failure from_slot/from_keylet best-effort clear the
slot they were given — a caller who was wrong about what it held is done
with it.
Inner objects are the exception. views::inner types (SignerEntry,
Signer, EmitDetails, HookExecution, …) have no type field of their
own to check — nothing in the protocol declares one for a nested object —
so their only constructor is an unchecked Xxx::from_slot(obj), taking
ownership of a child slot the caller already navigated to (an array
element, an object field) with no verification beyond what got you there.
Reading fields
Every accessor’s return type follows the field’s declared soe*
requirement:
let dest: AccountId = payment.destination()?; // soeREQUIRED -> Result<T>
let tag: Option<u32> = payment.destination_tag()?; // soeOPTIONAL -> Result<Option<T>>
A required field missing from a well-formed object is
HookError::DoesntExist; an optional or default-valued field reads as
Ok(None) when absent — never confused with a read failure, and decided on
the host’s raw return code rather than on a constructed HookError (the
same shape otxn_field_typed uses — see
Reading the Originating Transaction’s “Decoding a raw field”
section for the general shape of that concern). soeDEFAULT fields
read the same way as soeOPTIONAL: the format only records that the field
may be omitted, never what a hook should substitute, so supplying a default
is left to the hook.
A field whose serialized type is Amount/Issue reads back as
AmountBytes/IssueData (see Slots and Ledger Objects), the
same classify-by-length shapes the raw layer uses. A field whose serialized
type this crate models no scalar for (Blob, STObject, STArray, a
PathSet, …) gets a raw ..._into accessor instead, writing wire bytes
into a caller buffer; on a SlotSource view, an STObject/STArray field
additionally gets a ..._slot accessor that hands back an owned child slot
to navigate further — the one place a view’s accessor does not clear
after itself, since a container has no terminal read for “after” to mean
anything.
Fields every format of a kind shares — sfFlags, sfSourceTag on every
transaction; sfLedgerEntryType, sfFlags on every ledger entry — live once
on common-field traits (TransactionCommonFields,
TransactionCommonSlotFields, LedgerEntryCommonFields) that every
generated view implements, re-exported by the prelude.
A worked example: gating incoming IOU payments
examples/18_typed-views accepts an incoming payment only when it is a
native (XAH) payment — out of scope, accepted immediately — or an IOU
payment that carries a sfDestinationTag, is denominated in a currency this
account has an unfrozen trust line to the issuer for, and comes from an
issuer charging no transfer fee:
let Ok(payment) = tx::Payment::otxn() else {
rollback!(b"typed-views: not a Payment", ViewError::NotAPayment)
};
let Ok(amount) = payment.amount() else { .. };
let AmountBytes::Iou(iou) = amount else {
accept!(b"typed-views: native payment, not gated", 0)
};
match payment.destination_tag() {
Ok(Some(_)) => {}
_ => rollback!(.., ViewError::MissingDestinationTag),
}
// The line that gates *receipt* is this account's line to the issuer of
// the currency being paid — which the payment's own Amount names.
let keylet = keylet_line_for_asset(&me, &iou.asset())?;
let Ok(line) = ledger::RippleState::from_keylet(&keylet) else { .. };
(condensed from examples/18_typed-views/src/lib.rs; iou.asset() and
keylet_line_for_asset are covered in Slots and Ledger Objects
and Keylets.)
A RippleState has no fixed “sender”/“receiver” side — the protocol sorts
the two accounts canonically and calls the smaller one low, the larger
high, so lsfLowFreeze/lsfHighFreeze alone can’t answer “did we
freeze this line, or did they”. The example recovers the answer from the
line itself rather than re-sorting the accounts: sfLowLimit is an IOU
amount issued by the low account, so its issuer field is the low
account:
match line.low_limit() {
Ok(AmountBytes::Iou(low)) => buf_eq_20(&low.issuer().0, &me.0),
_ => false,
}
The obvious alternative — comparing me < asset.issuer directly, now that
AccountId has a loop-free Ord — looks cheaper (three fewer host calls)
and is measurably not: on this workspace’s opt-level = 3 profile,
low_limit() + buf_eq_20 costs fewer worst-case instructions than
me < asset.issuer (buf_cmp_20), because a host call is one instruction
in the worst-case count while buf_cmp_20 inlines a three-stage comparison
ladder. “Fewer host calls” and “fewer instructions” are different
objectives, and only the second is metered.
Cost
Every accessor is #[inline(always)] over a monomorphized source, so the
abstraction itself is free: an OtxnSource accessor is exactly one
otxn_field call, and calling it any number of times costs nothing beyond
each individual read.
A SlotSource accessor spends one thing a hand-written raw read need not:
every slot-backed read is get → read → clear, so a view’s accessors can
be called any number of times while consuming zero slots beyond the view’s
own root. A hand-written hook using SlotObject directly can skip that
clear and leak the child slot — the right trade for a one-shot read (see
Slots and Ledger Objects’s “Recycling with take_*”), and the
wrong one for a view whose accessors might be called from a loop. The
from_keylet/from_slot type check costs a few calls too — one
sfLedgerEntryType/sfTransactionType read, itself a get → read → clear.
examples/18_typed-views’s accept path — the whole gate above, IOU branch,
issuer charging no fee — is 18 host calls when the issuer sets no
sfTransferRate and 20 when it does:
| step | calls | what |
|---|---|---|
Payment::otxn() | 1 | otxn_type + one integer compare |
amount() | 1 | otxn_field |
destination_tag() | 1 | otxn_field |
hook_account_buf() | 1 | hook_account |
keylet_line_for_asset() | 1 | util_keylet |
RippleState::from_keylet() | 4 | slot_set, then the sfLedgerEntryType check |
line.flags() | 3 | slot_subfield + read + clear |
keylet_account() | 1 | util_keylet |
AccountRoot::from_keylet() | 4 | slot_set, then the sfLedgerEntryType check |
transfer_rate() | 1 or 3 | absent: slot_subfield reports it missing. Present: + read + clear |
Measured end to end (rshooks build/check, this workspace’s
opt-level = 3 profile) and recorded in
examples/18_typed-views/metrics.json — see that file for the current
worst-case instruction count, wasm size, and max nesting depth for the
main hook.
Every by-value fixed-size read above (hook_account_buf,
keylet_line_for_asset, keylet_account) has an _into(out: &mut T, ..) -> Result<()> twin that writes straight into caller-owned storage. The
by-value form is the idiom to write; the twin is an escape hatch for a
result that is only ever borrowed into the next call, where it saves one
copy of T per call site at the cost of a separate let mut x = T::default();. Measure with rshooks check before reaching for it — see
the rshooks::api module docs.
Feature gates: which views exist
Upstream’s format tables include formats unavailable on Xahau mainnet —
inherited wholesale from rippled, some amendment-blocked outright, some
Xahau-native but not yet activated.
crates/rshooks-core/format_availability.json is a curated, hand-maintained
classification of every declared format into one of three tiers, and the
generator gates each tier’s items with a #[cfg]:
| tier | meaning | default | active-amendments | all-amendments |
|---|---|---|---|---|
| active | activated on Xahau mainnet | yes | yes | yes |
| pending | supported by xahaud, not yet activated | yes | no | yes |
| dormant | no activation prospect on Xahau mainnet | no | no | yes |
active-amendments narrows the generated surface to formats actually live
today; all-amendments widens it to include dormant formats too, for a
custom network whose operator knows better. If both are enabled,
all-amendments wins, so enabling a feature can only add API, never remove
it. As of the vendored snapshot this crate ships against, no format is
classified pending — every format is either active or dormant — so in
practice the default surface and the active-amendments surface currently
coincide; active-amendments exists to keep meaning that once a pending
format is added, not to change anything today.
The sfield constants a view reads follow the same tiers, so a dormant
view and its dormant-only fields compile together or not at all — there is
no way to end up with a struct whose accessor references a field constant
that isn’t itself available. views::tx::Payment, views::ledger::RippleState,
and views::ledger::AccountRoot — everything used in the worked example
above — are all active, so nothing in this page needs any feature flag.
A dormant type (checked in crates/rshooks/src/views/ledger.rs/tx.rs/
inner.rs as #[cfg(feature = "all-amendments")]) needs:
rshooks = { version = "...", features = ["all-amendments"] }
The raw layers stay untouched and exhaustive regardless of any feature:
crate::tx_type::TxType and crate::ledger_entry_type::LedgerEntryType
decode every tt*/lt* code this crate knows about whether or not a typed
view exists for it, and otxn_field/SlotObject’s raw reads (see Reading
the Originating Transaction, Slots and Ledger
Objects) work on any field regardless of tier.
What views don’t do
Reach past views for what they deliberately leave to the layers underneath:
- No array iteration sugar. An
STArrayfield gives back raw bytes or aSlotObject<STArray>handle (via a..._slotaccessor); iterate it with the slot API and wrap each element in aviews::innertype by hand. - No builders. These are read views. Emitting a transaction is
rshooks::txn’s andrshooks::sto_writer’s job — see Emitting Transactions. - No help for a dynamic or unknown type. A view asserts the type it
claims. A hook reading one field, walking an array, or handling an object
whose type isn’t known in advance is better served by
rshooks::api::otxnorrshooks::slot_objdirectly.
Emitting Transactions
A hook doesn’t just accept or reject the transaction that invoked it — it
can also emit brand-new transactions of its own, which the network
processes independently once this hook returns. This page walks through
the emission lifecycle end to end: reserving emission slots, building a
transaction with txn_template!, emitting it, and reacting to the outcome
in a paired #[cbak(<index>)], using examples/10_emit-txn’s Payment
template as the worked example throughout.
The emission lifecycle
- 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 entry declares a paired
#[cbak(<index>)], the host calls it later, when the emitted transaction actually settles on ledger — or bounces.
if etxn_reserve(1).is_err() {
rollback!(b"emit-txn: etxn_reserve failed", EmitTxnError::ReserveFailed);
}
etxn_reserve must run before the corresponding emit call; it is not
optional bookkeeping. rshooks::api::etxn also exposes the lower-level
pieces this all rests on — etxn_burden, etxn_fee_base,
etxn_generation, etxn_nonce/etxn_nonce_buf — for hooks that need them
directly, but txn_template!’s prepare_for_emit() (below) already calls
the ones a typical Payment-shaped emission needs.
txn_template!
rshooks deliberately does not ship a built-in PaymentTemplate type —
any new field or transaction shape would then require a rshooks release.
Instead, mirroring xahaud’s own C “Tx Builder” split, txn_template! is a
declarative macro: you declare an ordered field list, and it generates a
byte-exact, fixed-offset template plus typed setters, computed entirely at
compile time.
txn_template! {
/// A payment template for emitted transactions.
struct Payment {
transaction_type = ttPAYMENT,
flags: sfFlags = tfCANONICAL,
source_tag: sfSourceTag = 0,
sequence: sfSequence = 0,
destination_tag: sfDestinationTag = 0,
first_ledger_sequence: sfFirstLedgerSequence = 0,
last_ledger_sequence: sfLastLedgerSequence = 0,
amount: sfAmount = NativeAmount(0),
fee: sfFee = NativeAmount(0),
signing_pub_key: sfSigningPubKey = [],
account: sfAccount,
destination: sfDestination,
emit_details: emit_details,
}
}
(from examples/10_emit-txn.) A bare field: sfXxx (or = default)
infers its kind straight from sfXxx’s serialized type — flags: sfFlags
above is exactly flags: u32_field(sfFlags), byte for byte. AMOUNT/VL
fields (amount/fee/signing_pub_key above) cover more than one wire
shape, so their kind can’t come from the STI alone — but it can still come
from the shape of the default: NativeAmount(0) infers native_amount,
[] infers empty_vl (the one spelling for an empty blob), and
IouAmount(xfl, cur, iss)/[ .. ]/*b".." infer amount/fixed_vl the
same way (see “amount: the 48-byte issued form” and “fixed_vl” below).
The uniform kinds below remain fully supported and are what a bare field
infers into; declare one explicitly only when neither the STI nor the
default’s shape disambiguates it (native_issue/issue, a zero-default
amount(sfX), or a fixed_vl default spelled as a named const rather
than a literal) or when the field is a nested object/array you’d
rather spell out. emit_details is the one structural marker (not a
kind), and must be declared last:
| kind | serialized type | wire bytes after header | default | setter |
|---|---|---|---|---|
u8_field(sfX) = e | UINT8 | 1 | required | set_x(u8) |
u16_field(sfX) = e | UINT16 | 2 | required | set_x(u16) |
u32_field(sfX) = e | UINT32 | 4 | required | set_x(u32) |
u64_field(sfX) = e | UINT64 | 8 | required | set_x(u64) |
hash128(sfX) | UINT128 | 16 | zeroed | set_x(&[u8; 16]) |
hash160(sfX) | UINT160 | 20 | zeroed | set_x(&[u8; 20]) |
hash256(sfX) | UINT256 | 32 | zeroed | set_x(&Hash) |
currency(sfX) | CURRENCY | 20 | zeroed | set_x(&CurrencyCode) |
native_amount(sfX) = e | AMOUNT | 8 | required drops | set_x(u64) -> Result<()> |
amount(sfX) | AMOUNT | 48 | IOU zero, zero currency/issuer | set_x(xfl, ¤cy, &issuer), set_x_value(xfl) |
amount(sfX) = (xfl, cur, iss) | AMOUNT | 48 | the declared triple | same as above |
native_issue(sfX) | ISSUE | 20 | zeroed | none |
issue(sfX) | ISSUE | 40 | zeroed | set_x(&CurrencyCode, &AccountId) |
account_id(sfX) | ACCOUNT | 1 + 20 | zeroed | set_x(&AccountId) |
empty_vl(sfX) | VL | 1 | empty blob | none |
fixed_vl(sfX, N) = e | VL | VL-prefix(N) + N | zeroed, or the declared [u8; N] | set_x(&[u8; N]) |
object(sfX) { .. } | OBJECT | inner + 1 (0xE1) | inner defaults | inner setters, prefixed |
array(sfX) [ .. ] | ARRAY | elements + 1 (0xF1) | inner defaults | inner setters, prefixed |
Every kind checks, at compile time, that the declared sfXxx constant’s
serialized type matches — u32_field(sfFee) (an issued/native AMOUNT
field) is rejected rather than silently writing the wrong wire
representation. Integer kinds are big-endian.
The macro computes cumulative byte offsets and the template’s total length
at compile time, bakes the field headers and defaults into a const fn new() (so the whole thing lands in a wasm data segment — see the statics
idiom below), and generates one setter per field that has one, per the
table above:
txn.set_amount(1).is_err(); // Result<()> — native_amount setters can fail out-of-range
txn.set_destination(&dest); // infallible — account_id setters
Required fields
An emitted transaction is invalid at the protocol level without six fields
plus EmitDetails, so every txn_template! declaration must include
all of them, each with the matching kind:
| required 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 |
Sequence/FirstLedgerSequence/LastLedgerSequence/Account can all be declared with the
inferred bare form (sequence: sfSequence = 0, account: sfAccount); Fee’s and
SigningPubKey‘s STIs (AMOUNT/VL) are ambiguous on their own, but their defaults’
shapes still disambiguate them: fee: sfFee = NativeAmount(0), signing_pub_key: sfSigningPubKey = []. A missing required field, or one declared with the wrong kind (sfFee as
u32_field instead of native_amount, say), is a compile error naming
exactly which field and check failed — never a runtime surprise. Declared
fields’ sfXxx codes must also be in strictly increasing canonical order,
which is a compile error too (and incidentally catches an accidental
duplicate field, since two equal codes can’t be strictly increasing).
amount: the 48-byte issued form
native_amount stays the 8-byte native (XRP/XAH) form; amount is always
the 48-byte issued (IOU) form: [8-byte value][20-byte currency][20-byte issuer]. The value bytes are a pure bit transform of the XFL —
xfl.raw_bits() | (1 << 63), big-endian — so encoding an amount field
needs no host call, at compile time or at runtime: an XFL’s canonical bit
layout already occupies the same positions STAmount’s issued 8-byte value
uses, and setting the top bit is STAmount’s own “not native” flag.
An amount(sfX) field with no default reserves the canonical IOU zero with
an all-zero currency and issuer; the host rejects an issued amount emitted
in that state (a real issuer is required), so leaving it unset is an
authoring bug the host surfaces at emit time, not something the macro
catches. A declared default bakes the currency and issuer into the data
segment instead:
amount: amount(sfAmount) = (XFL!(0), CurrencyCode::from_iso(b"USD"), account_id!("r...")),
// or, inferred straight from `sfAmount` and the default's shape:
amount: sfAmount = IouAmount(XFL!(0), CurrencyCode::from_iso(b"USD"), account_id!("r...")),
IouAmount(..) (like NativeAmount(..) above) is a syntax marker this desugar
recognizes, not a real type. Two setters follow from that split:
set_x(xfl, ¤cy, &issuer)rewrites all 48 bytes.set_x_value(xfl)writes only the 8 value bytes, keeping the baked or previously set currency/issuer — the intended hot path once a default triple has fixed the currency/issuer: one 8-byte store, no host call.
fixed_vl: a fixed-length blob
empty_vl(sfX) stays the empty blob; fixed_vl(sfX, N) is for a VL
field whose length is fixed by the declaration rather than empty — a memo
type code, a fixed-width tag, and the like. N (a usize const
expression, at least 1) is part of the declaration, so rippled’s VL length
prefix — one, two, or three bytes depending on N’s own magnitude — is
computed and baked in at compile time, the same as every other kind’s
header. N = 0 is a compile error: empty_vl is the one spelling for an
empty blob, so sfSigningPubKey’s required-kind check keeps accepting
only empty_vl.
memo_type: sfMemoType = *b"note",
memo_data: sfMemoData = [0; 8],
memo_type’s N = 4 and memo_data’s N = 8 are both inferred from the default literal
itself (*b"note"’s/[0; 8]’s own length) — the explicit fixed_vl(sfMemoType, 4)/
fixed_vl(sfMemoData, 8) spelling still works unchanged, and is the only option when the
default is a named const rather than a literal (there is then no literal to recover N
from). Without a default the payload is N zero bytes; a declared default must be exactly
[u8; N] — a wrong-length default is a compile-time type error, not a truncation. The
setter, set_x(&[u8; N]), is an infallible fixed-size write. Only fixed-length VL is
covered this way; a genuinely variable-length blob, Vector256, and PathSet stay out of
scope (see “Deferred kinds” below).
fixed_vl works the same way inside a nested container — a homogeneous
sfMemos array (see “Nested STObject/STArray” below) whose element
declares both fields:
memos: sfMemos [
Memo: sfMemo {
memo_type: sfMemoType = *b"note",
memo_data: sfMemoData = [0; 8],
}; 1
],
let Some(mut memo) = txn.memos(0) else {
rollback!(b"emit-txn: index out of range", EmitTxnError::IndexOutOfRange);
};
memo.set_memo_data(b"payload!");
examples/21_txn-template-nested carries exactly this memo alongside its
issued-amount entries.
Nested STObject/STArray
object(sfX) { <field>* } and array(sfX) [ .. ] nest a fixed inner field
list, or a fixed element list, directly inside a template — every
element’s count and shape is known at declaration time, so the whole thing
stays as compile-time-computable as the scalar kinds. An array’s elements
must each be an object(sfX) { .. }; a bare scalar, or another array,
directly inside an array is a compile error. array itself comes in two
forms.
Array elements
Each element is declared individually, so heterogeneous shapes — one native entry, one issued entry — fall out naturally, reached by its zero-based position in the list:
txn_template! {
struct Remit {
transaction_type = ttREMIT,
// .. the required fields, plus `destination: sfDestination` ..
amounts: sfAmounts [
sfAmountEntry {
amount: native_amount(sfAmount) = 1,
},
sfAmountEntry {
amount: sfAmount = IouAmount(XFL!(0), USD, USD_ISSUER),
},
],
emit_details: emit_details,
}
}
txn.set_amounts_0_amount(5)?; // first entry, 8-byte store
txn.set_amounts_1_amount_value(XFL!(1.5)); // second entry, 8-byte store
Setter names are the _-joined declaration path
(set_amounts_0_amount, set_amounts_1_amount/set_amounts_1_amount_value);
an element takes no name of its own — its position is just another path
segment, not a repetition index. An explicit name (native: sfAmountEntry { .. }) is a compile error.
Homogeneous, indexed elements
When every element has the same declared shape, array(sfX) [ Elem: object(sfY) { <field>* } ; N ] declares that shape once and reserves N
back-to-back copies of it (N a usize const expression, at least 1),
instead of one setter per element:
txn_template! {
struct Remit {
transaction_type = ttREMIT,
// .. the required fields, plus `destination: sfDestination` ..
amounts: sfAmounts [
AmountEntry: sfAmountEntry {
amount: sfAmount = IouAmount(XFL!(0), USD, USD_ISSUER),
}; 2
],
emit_details: emit_details,
}
}
let mut i: usize = 0;
loop {
guard!(2);
if i >= 2 {
break;
}
let Some(mut e) = txn.amounts(i) else {
rollback!(b"emit-txn: index out of range", EmitTxnError::IndexOutOfRange);
};
let Ok(value) = XFL::new(0, (i as i64).wrapping_add(1)) else {
rollback!(b"emit-txn: XFL::new failed", EmitTxnError::AmountValueFailed);
};
e.set_amount_value(value);
i = i.wrapping_add(1);
}
This generates an element-view type named Elem (AmountEntry above) —
AmountEntry::LEN, a baked AmountEntry::TEMPLATE default, and the same
inner setters (set_amount/set_amount_value) a template with that field
list would generate itself, all writing into a &mut [u8] view — plus, on
the parent, a runtime-indexed accessor named by the field path with no
set_ prefix: fn amounts(&mut self, index: usize) -> Option<AmountEntry<'_>>. None for index >= N is the whole
out-of-range story; there’s no txn.amounts[n] indexing operator (and no
unsafe/#[repr(C)] behind the view type) — the workspace’s
panic-on-out-of-range indexing lint would make a raw [n] unusable inside
a hook anyway, so Option plus a guarded loop is the idiom.
Choosing between them, and shared rules
Individually declared elements read best when each entry’s shape genuinely differs (a native amount next to an issued one, say); the homogeneous indexed form is for a repeated element shape whose count is fixed at declaration time, built or inspected through a loop rather than addressed one at a time. A few rules apply either way, once containers nest:
- Canonical
(type, field)order is checked per container: each object’s own direct fields must be strictly increasing, same as the template’s top-level fields. An array’s elements are not order-checked against each other — they typically share one repeatedsfcode(everysfAmountselement here is ansfAmountEntry). - Container headers and end markers (
0xE1closing anobject,0xF1closing anarray) are written at compile time, same as every other baked byte. - Nesting depth is bounded at compile time by
STO_WRITER_MAX_DEPTH, the same limit xahaud’s deserializer enforces — a homogeneous array’s element counts as two levels against that bound (the array itself, then the element), the same as an array’s own positional object element. - The six emit-plumbing fields (see “Required fields” above) are recognized
only at the top level — an
sfAccountnested inside some other object neither satisfies the presence check nor gets patched byprepare_for_emit. - Every field’s declared
sfXxxstill has its serialized type checked against its kind, a scalar or nestedarraydirectly inside anarrayis a compile error, and anemit_detailsfield inside any container is a compile error (it’s only meaningful once, at the top, last).
See examples/21_txn-template-nested for the worked example — a Remit
whose sfAmounts is a homogeneous, indexed array of compile-time-baked
issued entries, filled through the amounts(i) accessor (two entries,
written one after the other rather than from a loop) and emitted through
the same lifecycle as 10_emit-txn’s Payment.
Optional and variable-length fields
xahaud’s STObject/STArray deserializer skips a single 0x99 byte
(“NOP”) wherever it expects the next field’s header, on the emit path
only (never sto_*) — a present-or-absent field, or one of a
runtime-chosen length, can therefore keep a compile-time-fixed byte
offset: absence, or the unused tail of a variable-length slot, is just
more NOPs. txn_template! builds on this directly, so these fields
need no StoWriter:
optional <kind>(sfX)— any fixed-width kind, present or absent.any_amount(sfX)/optional any_amount(sfX)— one 49-byte slot holding either the 8-byte native form (the restNOP-filled) or the full 48-byte issued form.vl(sfX, MIN, MAX)/optional vl(sfX, MIN, MAX)— aVLblob whose length is chosen at runtime within[MIN, MAX], in a slot sized forMAX.optional object(sfX) { .. }/optional array(sfX) [ .. ]— a whole nested container, present or absent, with no view type: its own fields are plainset_x_<field>methods on the parent, and any of them makes the container present.array(sfX) [ Elem: optional object(sfY) { .. } ; N ]— a homogeneous, indexed array (see above) whose elements are individually present-or-absent, the array itself alwaysNslots long.
Every kind above that can infer from sfX’s own serialized type (the same
inference the required kinds above use for a bare field: sfX) has a
bare-sfX optional twin too: optional sfX, optional sfX { .. }/
[ .. ], and a homogeneous array’s Elem: optional sfY { .. } — same
budget, same generated API, only the spelling differs; a kind that cannot
infer (Amount, VL, Issue) still needs its explicit optional form.
An array’s own element (the homogeneous Elem: ..; N form aside) is
numbered by its zero-based position among every element in the list —
[ sfY { .. }, optional sfY { .. } ] reaches its elements as _0, _1.
Every container — the top level, each object/array, each homogeneous
array, each named optional object/optional array — has its own
63-NOP budget: the worst case over its direct optional/variable
children (every optional field absent, every vl at MIN, every
any_amount native) must fit, checked at compile time with a message
naming the container and the budget. This is why a hook author sometimes
has to nest a field into its own small container, or choose a
per-element array over a homogeneous one, rather than declare fields
alongside each other freely: examples/22_txn-template-optional’s
Remit::amounts is an array with one required and one optional
element, both numbered by position (sfAmountEntry { amount: sfAmount = AnyAmount() }, optional sfAmountEntry { .. }) — it can hold what two
fully optional 52-byte sfAmountEntry elements (2 * 52 = 104 > 63)
could not, since a required element charges its container nothing
(Elem::LEN, not Elem::LEN reserved-but-optional) while only the second
(optional) element’s own 52 bytes count against the array’s budget.
The generated API mirrors fixed_vl/homogeneous-element setters: set_x
writes the field (making it present), clear_x restores the NOP-filled
default; a named optional object/optional array gets no view type at
all — its own fields flatten onto the parent like a plain nested
container’s, and any one of them (or a generated enable_x(&mut self))
first materializes it and every enclosing optional ancestor, if absent,
before writing; clear_x(&mut self)/is_x_present(&self) -> bool round
it out. A homogeneous array whose element is itself optional keeps its
element view type (the array is indexed at runtime), and that view’s own
setters gained the same auto-presenting behavior, alongside its existing
enable()/clear()/is_present() (Elem::enable()/Elem::clear() —
see crates/rshooks/src/txn.rs’s mod tests and
crates/rshooks/tests/ui/pass/txn_template_optional.rs for a worked
example; examples/22_txn-template-optional uses the named-array form
instead). From 22_txn-template-optional:
if let Ok(Some(tag)) = self.hook_param.dest_tag.get() {
txn.set_destination_tag(u32::from_be_bytes(tag)); // optional sfDestinationTag
}
if let Ok(Some(bytes)) = self.hook_param.amt2.get() {
// optional sfAmountEntry { .. } (unnamed, position 1) -- this setter
// makes it present.
txn.set_amounts_1_amount_native(u64::from_be_bytes(bytes))?;
}
NOP-padded bytes must never reach the sto_* family
(sto_validate/sto_subfield/sto_subarray/sto_emplace/sto_erase):
the Hook API’s own lightweight parser rejects STI_NUMBER (the NOP’s
type) outright, unlike xahaud’s own deserializer on the emit path. No
rshooks code hands sto_* a NOP-padded region today; keep it that way
in hook-side code too. See docs/NOP_PADDING_DESIGN.md for the full
design, including the exact worst-case-NOP formula per kind.
Deferred kinds
Vector256 and PathSet — distinct wire shapes from a plain VL blob
(Vector256 is a flat run of 32-byte hashes with no per-element header;
PathSet is its own nested path/step grammar) — have no txn_template!
kind yet; see docs/TXN_TEMPLATE_FIELDS_DESIGN.md §6 for what’s deferred
and why. A field of one of these types still needs StoWriter (below) or
hand-rolled bytes.
prepare_for_emit()
Because those seven fields are mandatory, every txn_template! invocation
that compiles gets a prepare_for_emit(&mut self) -> Result<Prepared<'_, Self>> for free. It:
- 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(<index>)]: reacting to the outcome
A Hook entry can optionally pair its #[hook(<index>, ...)] with a
#[cbak(<index>)] at the same index, exporting cbak for that entry’s own
build. The host invokes it later — in a separate execution — when a
transaction this hook previously emitted settles on ledger, whether it
succeeds or bounces:
#[hooks(description = "Emits a Payment and handles its callback.")]
pub struct EmitTxn;
#[hooks]
impl EmitTxn {
#[hook(0, name = "emit-tx", on = [Invoke], can_emit = [Payment])]
fn main(&self) -> HookResult { /* ... */ }
#[cbak(0)]
fn cbak(&self, outcome: EmitOutcome) -> HookResult {
match outcome {
EmitOutcome::Applied => Ok(Accept::new(b"emit-txn: applied", 0)),
EmitOutcome::EmitFailure => Ok(Accept::new(b"emit-txn: emit failure", 1)),
}
}
}
(from examples/10_emit-txn; a real callback typically also inspects the
settled transaction’s metadata via SlotObject::from_meta() — see
Slots and Ledger Objects — once it knows the emission
actually applied.) #[cbak(<index>)] takes only the index — no name/
on/etc. of its own, since it settles for whatever its paired #[hook]
at that same index emitted. Its fn may declare one argument after &self
— EmitOutcome (or a raw u32) — populated from the host’s cbak(u32)
argument. Declaring a #[cbak] changes that entry’s EmitDetails real
serialized size (138 bytes instead of 116), which is exactly why
prepare_for_emit reads etxn_details’s returned length rather than
assuming a fixed one.
The EmitFailure trap
An emission is not guaranteed to apply: if its LastLedgerSequence passes
before it settles, xahaud never applies it as its own transaction type.
Instead it applies a ttEMIT_FAILURE pseudo-transaction carrying
sfLedgerSequence, sfTransactionHash (the emitted transaction’s hash),
and the original sfEmitDetails. That pseudo-transaction — not the
emitted transaction — is the callback’s own originating transaction, and
its metadata reports tesSUCCESS regardless of what the real emission
would have done. Reading meta_slot → sfTransactionResult without
checking the outcome first therefore reports success for an emission that
never delivered. Gate on EmitOutcome::Applied before trusting
meta_slot (equivalently, otxn_type() != TxType::EmitFailure), and in
the EmitFailure arm read sfTransactionHash to learn which emission
expired.
can_emit on the entry attribute
Emitting a transaction of a given type is itself a capability a Hook entry
must declare. #[hook(<index>, ...)]’s can_emit list names every
transaction type this entry’s wasm might emit:
#[hook(0, name = "emit-tx", on = [Invoke], can_emit = [Payment])]
fn main(&self) -> HookResult { /* ... */ }
rshooks build cross-checks a declared can_emit against whether the
compiled entry’s wasm actually calls emit — a mismatch either way (a
declared type never emitted, or an emit with no matching declaration)
surfaces as a build-time warning, never a hard error. See Per-Hook
Attributes for the full attribute reference,
including the three-state semantics of an omitted vs. explicitly empty
can_emit, and how it interacts with on and the other per-entry
attributes.
Runtime-shaped transactions: StoWriter
txn_template! covers fixed-shape nested containers directly (see “Nested
STObject/STArray” above), including present-or-absent fields and
variable-length blobs within a fixed MAX (see “Optional and
variable-length fields” above) — what it cannot describe is a runtime
element count: an sfAmounts whose entry count isn’t fixed by the
declaration (examples/17_sto-writer’s Remit, one entry per destination,
depending on what the invoking transaction’s hook parameters supply), or
any shape needing more than one array’s 63-NOP budget can hold. That
case needs rshooks::sto_writer::StoWriter instead: a bounded,
allocation-free cursor over caller-owned storage with its own
prepare_for_emit()/Prepared::emit() lifecycle, built directly on top of
the same Prepared type this page’s prepare_for_emit() returns. See
The StoWriter API.
The StoWriter API
Emitting Transactions covers txn_template!: a declarative
macro that bakes a transaction’s field offsets and total length into a
const fn, computed entirely at compile time — including a fixed-shape
nested STObject/STArray, such as a two-entry sfAmounts whose element
count and shapes are known at declaration time (see Emitting
Transactions), and, via its
NOP-padded kinds, a present-or-absent field or a runtime-chosen-length
blob within a fixed MAX (see Optional and variable-length
fields). What
txn_template! cannot describe is a runtime element count: Remit’s
sfAmounts, one sfAmountEntry per destination, whose count depends on
what the invoking transaction’s hook parameters supply — more entries
than any one array’s 63-NOP budget could hold present-or-absent.
rshooks::sto_writer::StoWriter is the runtime counterpart for that
case: a bounded, allocation-free cursor over caller-owned storage that writes
field headers, tracks open containers, and checks every write against the
buffer’s real bounds. This page walks through it end to end using
examples/17_sto-writer’s Remit hook as the worked example throughout —
the same emission lifecycle Emitting Transactions covers
(reserve, build, emit, react), with StoWriter standing in for
txn_template! at the “build” step.
Field order is caller-supplied, not canonical
StoWriter writes fields in exactly the order its methods are called and
never reorders or validates that order. xahaud accepts a serialized
object’s fields in any order and always re-serializes sorted by field code,
so the on-ledger transaction is canonically ordered regardless of write
order — writing fields outside ascending (type, field) order changes
nothing about validity or etxn_fee_base (the serialized size is the same
either way; only in-buffer field position differs). What is enforced:
every write is checked against the buffer’s real bounds with
overflow-checked cursor arithmetic; begin_object/begin_array and
end_object/end_array must match (an STArray’s direct children may
only be opened with begin_object — a bare scalar or nested array directly
inside an open array is rejected); nesting is bounded by
STO_WRITER_MAX_DEPTH (10); and no write succeeds once prepare_for_emit
has finalized the writer.
Building a transaction
StoWriter::new(buf) wraps caller-owned storage as a fresh writer, empty,
at the top-level container. StoWriter::resume(buf, prefix_len) instead
starts the cursor at prefix_len, trusting the caller that buf[..prefix_len]
already holds a valid serialized prefix — typically one baked at compile
time into a static, so it costs a wasm data segment instead of runtime
instructions. A resumed writer carries no emit-plumbing bookkeeping for
that prefix until emit_plumbing(offsets) supplies it: a PlumbingOffsets
naming where the four patchable fields (sfFirstLedgerSequence,
sfLastLedgerSequence, sfFee, sfAccount) live, standing in for the
field-by-field calls that would otherwise have recorded those offsets
themselves. examples/17_sto-writer uses this to bake its entire
emit-plumbing prefix at compile time — see the updated build_remit below.
Scalar fields have one method per STI_* shape:
| method | writes |
|---|---|
u16_field(f, value) | an STI_UINT16 field (e.g. sfTransactionType) |
u32_field(f, value) | an STI_UINT32 field (e.g. sfFlags, sfSequence) |
account_id(f, &value) | an STI_ACCOUNT field (a 1-byte VL length of 20, then the 20 raw bytes) |
empty_vl(f) | an STI_VL field as an empty blob (a 1-byte zero-length marker) — what SigningPubKey looks like on an emitted transaction |
vl(f, value) | an STI_VL field with a caller-supplied length prefix and payload — rippled’s 1/2/3-byte VL length encoding, sized to value.len() |
native_amount(f, drops) | an STI_AMOUNT field encoded as a native (XRP/XAH) amount |
iou_amount(f, xfl, ¤cy, &issuer) | an STI_AMOUNT field encoded as an issued amount, via the float_sto host call |
iou_amount is the runtime counterpart of txn_template!’s amount kind
(see Emitting Transactions) —
same 48-byte issued layout, written through a host call here instead of
baked in at compile time. Likewise, vl is the runtime counterpart of
txn_template!’s fixed_vl(sfX, N) kind: the same length-prefix encoding,
computed from a runtime value.len() here instead of baked in for a
compile-time-fixed N. vl is the one writer whose payload length the
caller controls: pass a &[u8; N] (or another length the optimizer can
see as a constant at the call site) so the copy stays a fixed-size store
after inlining — a genuinely runtime-sized slice can lower to a
compiler-generated copy loop, which rshooks build’s guard checker
rejects. empty_vl(f) is exactly vl(f, &[]) plus the
SigningPubKey plumbing bookkeeping vl does not do — prefer empty_vl
there.
Containers nest with begin_object(f)/end_object() (an STObject field,
e.g. sfAmountEntry) and begin_array(f)/end_array() (an STArray
field, e.g. sfAmounts) — legal directly inside an STObject; an
STArray’s direct children may only be begin_object, never a bare scalar
or a nested array. as_bytes()/len()/is_empty() read back what has
been written so far at any point, including mid-construction with open
containers — unlike Prepared::as_bytes, this needs neither the container
stack closed nor any emit-plumbing field present.
examples/17_sto-writer’s build_remit builds a Remit with a native
sfAmounts entry always, plus a second, issued-amount entry only when the
hook’s CUR/ISSUER parameters are both present:
fn build_remit<'a>(
buf: &'a mut [u8; BUF_LEN],
destination: &AccountId,
issued: Option<&(CurrencyCode, AccountId)>,
) -> Result<StoWriter<'a>> {
let mut w = StoWriter::resume(buf, PREFIX_LEN)?;
w.emit_plumbing(PREFIX.1)?;
w.account_id(sfDestination, destination)?;
w.begin_array(sfAmounts)?;
w.begin_object(sfAmountEntry)?;
w.native_amount(sfAmount, 1)?;
w.end_object()?;
if let Some((currency, issuer)) = issued {
w.begin_object(sfAmountEntry)?;
w.iou_amount(sfAmount, XFL::one(), currency, issuer)?;
w.end_object()?;
}
w.end_array()?;
Ok(w)
}
(from examples/17_sto-writer.) The field order here — TransactionType,
Flags, Sequence, …, Amounts — reads naturally top-to-bottom, but
nothing about StoWriter requires it; see “Field order” above.
Required fields and duplicate rejection
StoWriter detects the same six required emit-plumbing fields
txn_template! does — sfSequence, sfFirstLedgerSequence,
sfLastLedgerSequence, sfFee, sfSigningPubKey, sfAccount — by value,
as they are written, recording an offset (or a presence flag, for
Sequence/SigningPubKey) for prepare_for_emit to patch or verify
later. Because FirstLedgerSequence/LastLedgerSequence/Account/Fee
are patched at each field’s recorded offset, a second write of any of
these six fields would leave the first occurrence unpatched or duplicated
in the emitted blob — a serialized object cannot repeat a field — so a
repeat write is rejected with HookError::AlreadySet. Any other field may
be written more than once as far as StoWriter is concerned; whether a
repeated non-plumbing field is otherwise valid is between the caller and
the host.
prepare_for_emit() and Prepared::emit()
There is no public emit_details method on StoWriter — unlike
txn_template!’s generated type, which declares emit_details as a
structural marker field in its field list, StoWriter::prepare_for_emit
appends the runtime-sized sfEmitDetails field itself, at the current
cursor, once every container the caller opened has been closed:
- Requires every container to be closed (
depth == 0) and all six required fields to have been written; otherwiseHookError::InvalidArgument. - Patches
FirstLedgerSequence/LastLedgerSequencefromledger_seq() + 1/+ 5(i.e.FirstLedgerSequence + 4), the same valuestxn_template!’sprepare_for_emitcomputes. - Patches
Accountfromhook_account(). - Appends
sfEmitDetailsat the cursor viaetxn_details, trusting its returned length (116 bytes without a#[cbak]export, 138 bytes with one) —bufmust have at leastEMIT_DETAILS_MAX_LEN(138) bytes of headroom beyond everything already written, or this step fails withHookError::InvalidArgument. - Computes
etxn_fee_baseover the full serialized prefix, including the just-appendedEmitDetails, and patchesFee. - Finalizes the writer (every write after this point fails with
HookError::InvalidArgument) and returns aPrepared<'_, StoWriter<'_>>handle sized to exactly what was written.
Sequence and SigningPubKey are left untouched (checked for presence
only) — exactly as in txn_template!’s macro-generated
prepare_for_emit. StoWriter::prepare_for_emit returns the same
crate::txn::Prepared type txn_template!’s does, so Prepared::emit()
— the thin wrapper over rshooks::api::etxn::emit_buf that passes exactly
Prepared::as_bytes() — works identically either way:
let Ok(mut w) = build_remit(buf, &destination, issued.as_ref()) else {
rollback!(b"sto-writer: build failed", StoWriterError::BuildFailed)
};
let Ok(prepared) = w.prepare_for_emit() else {
rollback!(
b"sto-writer: prepare_for_emit failed",
StoWriterError::PrepareFailed
)
};
match prepared.emit() {
Ok(_hash) => accept!(b"sto-writer: emitted", 0),
Err(_) => rollback!(b"sto-writer: emit failed", StoWriterError::EmitFailed),
}
(from examples/17_sto-writer.) As with txn_template!, etxn_reserve
must already have been called before prepare_for_emit/emit — neither
calls it for you.
The statics idiom, again
StoWriter’s backing buffer belongs in a static for the same reason
Emitting Transactions
gives for a txn_template! type: a HookStatic-held buffer lands in a
wasm data segment/BSS instead of being materialized by runtime stores,
which matters even more here given StoWriter’s buffers tend to be larger
than a single fixed-shape template’s. examples/17_sto-writer follows the
same pattern:
const BUF_LEN: usize = 285;
static BUF: HookStatic<[u8; BUF_LEN]> = HookStatic::new({
let mut buf = [0u8; BUF_LEN];
codec::write_const_bytes(&mut buf, 0, &PREFIX.0);
buf
});
let Some(buf) = BUF.take() else {
rollback!(
b"sto-writer: static buffer already taken",
StoWriterError::BufferAlreadyTaken
);
};
Buffer sizing
BUF_LEN = 285 covers the fixed emit-plumbing prefix (95 bytes:
TransactionType/Flags/Sequence/FirstLedgerSequence/
LastLedgerSequence/Fee/SigningPubKey/Account/Destination/
Amounts’ own header/AmountEntry’s own header/a native Amount/its
ObjectEndMarker/the ArrayEndMarker), plus a second, issued-amount
AmountEntry sized for the worst case — both hook parameters present (52
bytes: header + a 48-byte STAmount + ObjectEndMarker) — plus
EMIT_DETAILS_MAX_LEN (138 bytes), the headroom prepare_for_emit
requires beyond everything already written. Sizing a StoWriter buffer is
manual in exactly this way — there is no macro to compute it, since the
shape is runtime-dependent by design.
Unit tests
examples/17_sto-writer exercises the real StoWriterRemit entry through
rshooks_testenv::TestEnv::invoke, in both layouts Off-Chain Unit
Tests covers: tests/remit.rs and an in-crate
#[cfg(test)] module. Both cover the full
prepare_for_emit()/Prepared::emit() path with the sfAmounts array
present — native-only and native-plus-issued shapes, the DEST-missing
rollback, and cbak — and, in-crate only, build_remit/prepare_for_emit
itself against a small local HostBackend mock for byte-level assertions
(build_remit is private, so only an in-crate test can call it directly;
see rshooks::raw::backend on The Raw Layer for
what that mock hooks into).
Cost, here
examples/17_sto-writer/metrics.json records the main entry’s worst-case
instruction count, size, and max nesting depth as built by rshooks build/check (examples/’s opt-level = 3 profile). It sits well inside
the 65,535-instruction WCE ceiling and the 65,535-byte SetHook size limit,
and above 10_emit-txn’s fixed-template Payment, which is expected: this
hook does strictly more work at runtime (two hook-parameter reads, a
conditional issued-amount branch, and StoWriter’s own bounds/duplicate
checks on every field, versus a const fn-baked template with none of that
at runtime).
The rshooks CLI
rshooks is a single binary with three subcommands: build (the
one you’ll use for everyday work), clean (post-process an
already-compiled wasm without invoking cargo), and check (validate any
wasm file against the full SetHook rule set, without modifying it). This
page is the complete flag reference for all three, taken directly from the
CLI’s own definitions.
Every subcommand also accepts the standard clap-generated -h/--help;
rshooks --version prints the installed version.
rshooks build
Builds a #[hooks] crate for wasm32v1-none: one discovery build to read
its declarations, then one cargo rustc build per declared index, each
cleaned and validated into its own SetHook-legal binary. This is the
pipeline described in Building a Hook and
Hook Chains.
rshooks build --manifest-path path/to/Cargo.toml
| flag | default | description |
|---|---|---|
--manifest-path <PATH> | cargo’s default (current directory) | Path to the crate’s Cargo.toml, forwarded to every cargo invocation. |
-p, --package <NAME> | none | Build only the named package, forwarded to cargo’s -p. Useful when --manifest-path points at a workspace. |
--out <DIR> | target/rshooks/<crate-name> under the workspace’s target directory | Output root: generation directories (gen-<N>/) are written under it, with current symlinked to the latest complete, validated one. |
--allow-oversize | off | Write each index’s output even if it exceeds the 65,535-byte SetHook size limit. The result is still clearly marked invalid in the printed report. |
--no-optimize | off | Skip the Binaryen wasm-opt -Oz size-optimization pass that otherwise runs on each entry’s raw wasm before cleaning. |
--account <r...> | none | Fill the generated template’s Account placeholder with this address. |
--namespace <64hex> | none | Fill the generated template’s HookNamespace placeholder(s) with this value. |
--override | off | Add hsfOVERRIDE (Flags: 1) to every declared (non-gap) entry in the generated template, permitting replacement of an already-installed Hook at that position. Never applied to gap ({"Hook": {}}) entries. |
On success, build writes, under <out-root>/current/: one
<index>.<fn>.wasm and one <index>.<fn>.metadata.json per declared entry,
plus sethook.template.json and sethook.template.meta.json covering the
whole chain — see Per-Hook Attributes for the sidecar and
template’s exact shape.
rshooks clean
Runs the same post-processing pipeline as build — the Binaryen wasm-opt
-Oz pass, the cleaner (drops custom sections and any export other than
hook/cbak, then garbage-collects), flatten (inlines every defined
helper function into hook/cbak), unnest, and the authoritative guard
check — on one already-compiled wasm file from any toolchain, without
invoking cargo. Useful for post-processing a single artifact you already
have on disk — for example one index’s raw build output from a different
pipeline, or one you want to reprocess with different flags without
rebuilding.
This makes clean usable as a post-processor for Hooks written in C and
compiled with clang: C authors can write ordinary (non-inline) helper
functions, and loops with GUARD inside them, exactly as they would in any
other C program, and clean inlines those helpers into hook/cbak, so
the type section reduces to the import types plus the entry-point type, as
SetHook requires.
rshooks clean path/to/artifact.wasm
clang --target=wasm32 -mcpu=mvp -nostdlib -O2 \
-Wl,--no-entry -Wl,--allow-undefined -Wl,--export=hook -Wl,--export=cbak \
-o hook.raw.wasm hook.c
rshooks clean hook.raw.wasm -o hook.wasm
-mcpu=mvp keeps clang from emitting post-MVP instructions (such as
sign-extension ops) in the first place. Without it, clean stops before
the wasm-opt pass with an error naming the flag, because that pass only
accepts modules within the WebAssembly MVP instruction set; with
--no-optimize (or under check) such a module may still pass the
authoritative upstream guard checker, but a divergence warning is printed,
since the Rust validator enforces the MVP instruction set. Compile with
-O2 or higher: at -O0/-O1 clang does not keep the _g call as the
first instruction of every loop, so the raw output only passes the guard
check when the wasm-opt pass is left on.
A helper containing a guarded loop is duplicated at each call site while
keeping one guard id, so size its maxiter for the total across all call
sites. See Guards and Loops.
| flag | default | description |
|---|---|---|
input (positional) | — | The input wasm file. Required. |
-o, --out <PATH> | <input>.clean.wasm | Where to write the cleaned binary. |
--allow-oversize | off | Write the output even if it exceeds the 65,535-byte SetHook limit. |
--no-optimize | off | Skip the Binaryen wasm-opt -Oz size-optimization pass that otherwise runs on the raw wasm before cleaning. |
clean does not generate a metadata sidecar or a SetHook template — those
steps are specific to build, since they need the original crate’s
#[hooks] carriers from cargo’s raw discovery artifact, and clean
operates on a single already-processed wasm file with no such carrier left
in it.
rshooks check
Validates a wasm file against the full SetHook rule set without modifying
it. Like clean, this works on any wasm file, including ones not
built by this toolchain at all — for example, a Hook compiled from C.
rshooks check path/to/hook.wasm
| flag | default | description |
|---|---|---|
file (positional) | — | The wasm file to validate. Required. |
On success, check prints the same worst-case-instruction and
nesting-depth report as build/clean, followed by OK: <file> is a valid SetHook wasm binary and the size/fee estimate. On failure, it
prints INVALID: <file> failed validation: with the specific reasons, and
exits with a non-zero status — making it suitable for a CI gate on hand-
written or third-party wasm as well as this toolchain’s own output.
Per-Hook Attributes and the SetHook Template
A #[hooks] chain declares its descriptive and SetHook-facing metadata
directly on the struct and on each entry, rather than in a separate
top-level block. rshooks build reads these declarations and turns them
into a JSON sidecar per entry, plus one SetHook transaction template
covering the whole chain. This page is the full grammar for both attribute
forms and the exact shape of everything the build generates from them —
Hook Chains covers the concepts (index, shared
schema, the template’s patch semantics) this page assumes.
The struct-level attribute: #[hooks(description = "...")]
#[hooks(description = "20-seat L1/L2 governance and reward chain")]
pub struct Governance { /* ... */ }
description is the only argument #[hooks] accepts on a struct —
optional, free-form text carried into every entry’s sidecar under its
chain object (below). There is deliberately no struct-level name: a
crate’s identity for tooling purposes is its Cargo package name, and an
on-ledger HookName is a per-entry concern (below), since two entries in
the same chain can be named differently, the same, or not at all.
The per-entry attribute: #[hook(<index>, ...)]
#[hook(0, name = "govern", on = [Invoke], can_emit = [Invoke, SetHook], description = "Governance state machine")]
fn govern(&self) -> HookResult { /* ... */ }
- Leading positional argument,
0..=9— required, no default. This entry’s index; see Hook Chains for what it means. name = "..."— optional, this entry’s on-ledgerHookName. See “HookName” below for the length rule.on = .../on_incoming+on_outgoing— optional; see “Trigger forms” below. Mutually exclusive with each other.can_emit = [Tx, ...]— optional; see “The three states ofcan_emit” below.description = "..."— optional, free-form text for this entry’s sidecar (independent of the struct’s owndescription).
#[cbak(<index>)] takes only the index — no other attribute arguments, since a
callback doesn’t get its own trigger or emit declaration; it settles for
whatever its paired #[hook] at the same index emitted.
Transaction type names
Every entry in on, on_incoming, on_outgoing, and can_emit is a bare
TxType variant name — Payment, not
TxType::Payment or ttPAYMENT. Because the macro resolves each name
against the real enum, a misspelling is a compile error, not a silent
no-op. Duplicate entries within one list are also rejected.
Names use Xahau’s canonical TransactionType spellings, including some
that are easy to get wrong by guessing: SetHook, SetRegularKey, and
AMMCreate. rshooks maintains the authoritative list of every
valid name against the actual protocol transaction set.
Trigger forms
Each entry chooses one of four trigger forms:
| form | wire output | meaning |
|---|---|---|
| omitted entirely | no trigger field at all | No installation override. For a brand-new HookDefinition, this means the protocol default: fires on every transaction type except SetHook, automatically tracking any type added to the protocol later. If this entry’s wasm already has an existing HookDefinition on-ledger (an Update, not a fresh Install), the existing definition’s trigger is inherited instead — so omission is not a portable guarantee of “fires on everything,” only “don’t say.” |
on = all | an explicit all-zero HookOn mask (every ordinary bit clear, the SetHook bit set so it alone doesn’t fire) | Guaranteed catch-all, tracking future transaction types the same way omission’s new-definition case does, but without depending on whether this is an Install or an Update. |
on = [Payment, Invoke, ...] | a HookOn mask covering exactly the listed types | Fires only for the listed types. on = [] is legal and means “never fires.” |
on_incoming = [..] + on_outgoing = [..] | HookOnIncoming + HookOnOutgoing (mutually exclusive with HookOn) | Direction-sensitive triggering (HookOnV2). Must be declared as a pair — one without the other is a build error. If both sets would end up identical, the build rejects it and asks for plain on instead, since that’s what it means. |
Pick on = all when you need a guaranteed, future-proof catch-all
regardless of Install/Update history; pick omission only when “whatever
this position already has, if anything” is genuinely what you mean.
The three states of can_emit
can_emit has the same “omitted vs. explicitly empty vs. a list”
three-state shape as on, and it matters just as much here — an omitted
can_emit is not the same as an empty one:
| declaration | wire output | meaning |
|---|---|---|
| omitted | no HookCanEmit field | No installation override — inherits an existing definition’s emit permissions on Update, or (for a fresh Install) no restriction at all. |
can_emit = [] | an explicit deny-all HookCanEmit mask | This entry may emit nothing. |
can_emit = [Payment, ...] | a HookCanEmit allowlist mask | This entry may emit only the listed types. |
rshooks build cross-checks each entry’s declared can_emit against
whether its own compiled wasm actually calls emit (checked on the
final, per-index-cleaned binary, so unreachable code in a shared crate
never counts). Emitting without permission to, or declaring permission
never used, both surface as build-time warnings — never a build failure —
naming the specific mismatch. A #[cbak] declared for an entry that never
actually calls emit gets the same warning treatment, in the other
direction.
HookName
HookName is a Rust UTF-8 string. The macro itself enforces 2 through 8
Unicode scalar values — deliberately counting characters, not encoded
bytes. This is a separate rule from xahaud’s own ledger-level requirement
that a HookName be 4 through 16 UTF-8 bytes; a name intended for
direct on-chain submission needs to satisfy both. Because these two rules
can diverge for non-ASCII names, rshooks build checks the
byte-length rule too and prints a warning (not a hard error) when a
declared HookName doesn’t fit it. Two entries in the same chain sharing
a name is legal protocol-wise, and produces an informational note rather
than a warning or error.
How the declarations travel through the build
Both #[hooks] macros carry their declarations as compact JSON, hex-
encoded into the names of dead wasm exports that are never actually
called: the struct macro emits one (prefix __rshooks_chain_v2_, the
shared schema), and the impl macro emits another (prefix
__rshooks_hooks_v2_, every entry’s per-index metadata). rshooks build
reads both from the discovery build’s raw artifact (see Building a
Hook), then re-checks that each
per-index build’s own carriers are byte-identical to discovery’s, before
the ordinary hook-cleaner pass removes them along with every other
non-hook/cbak export. These declarations are build-only and never
change any deployed binary: they add no data segment, no runtime code,
no import, and no byte to the final wasm.
The per-entry JSON sidecar
For Governance’s govern entry (index 0, on = [Invoke],
can_emit = [Invoke, SetHook], name = "govern"), rshooks build writes
out/current/0.govern.metadata.json:
{
"index": 0,
"hook_fn": "govern",
"cbak_fn": null,
"name": "govern",
"description": "Governance state machine",
"HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
"HookCanEmit": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFF9FFFFD",
"HookName": "676F7665726E",
"HookHash": "…64 hex chars…",
"WCE": { "hook": 27751, "cbak": 0 },
"builder": {
"name": "rshooks-build",
"version": "0.2.3",
"rustc": "rustc 1.89.0 (29483883e 2025-08-04)",
"cargo_args": ["rustc", "--release", "--locked", "--target", "wasm32v1-none", "--crate-type", "cdylib"],
"rustc_args": ["--cfg", "rshooks_entry=\"0\"", "--check-cfg", "cfg(rshooks_entry,values(\"0\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\"))", "-C", "link-arg=-zstack-size=131072"],
"wasm_opt": true
},
"human": {
"HookOn": ["Invoke"],
"HookCanEmit": ["Invoke", "SetHook"],
"HookName": "govern"
},
"chain": {
"struct": "Governance",
"description": "20-seat L1/L2 governance and reward chain",
"decls": {
"state": [
{ "field": "reward_rate", "kind": "const", "key": "b\"RR\"", "value": "XFL" }
],
"hook_params": [],
"otxn_params": []
}
}
}
Fields not covered already on this page:
index/hook_fn/cbak_fn— this entry’s declared position, its hook function’s name, and its cbak function’s name (nullif this index declares none).- Top-level
HookOn/HookOnIncoming/HookOnOutgoing/HookCanEmit— the raw, deployable SetHook value: a 32-byte hex string encoding Xahau’s transaction-type bitmask.nullwhen the corresponding attribute was omitted (never for an explicitly emptyon = []/can_emit = [], which still produce a real mask). HookHash— the uppercase hex of the first 32 bytes of this index’s own final cleaned wasm’s SHA-512 digest — identifies this one entry’s code, independent of chain position or which account installs it.human— the readable, source-level form of every masked/hex field above. Usehumanto review what an entry declares; use the top-level fields when constructing an actualSetHooktransaction.sig_params— this entry’s declared signature parameters (Hook and Transaction Parameters), in wire-index order. Present only when the hook crate is built with theunstable-param-sig-interfacefeature — absent otherwise (as forgovernhere). When present, it is anull-free array, empty for an entry with no signature-parameter fn arguments. Each element is{ "field", "type_byte", "name_hex" }— the argument’s own identifier, its type code (an XAS-010d type code), and the full declaredHookParameterNameas uppercase hex, the same value the generatedHookParametersdeclaration entries below use verbatim.builder.cargo_args/builder.rustc_args/builder.wasm_opt— the reproducibility record: the machine-independentcargoarguments and the verbatimrustcarguments (after--) this entry was built with, plus whether thewasm-opt -Ozpipeline pass ran. The-zstack-sizelink argument is the memory layout: a 2-page stack, with wasm-ld sizing linear memory to what the stack plus data/bss need.chain— this crate’s shared schema, transcribed identically into every entry’s sidecar (not filtered down to what this one entry actually uses — see Hook Chains for why “declared” and “used by this entry” are deliberately different things here).declslists every#[state]/#[hook_param]/#[otxn_param]field on the struct, however many entries reference it.
The SetHook template and its generation sidecar
Once every declared index has built and validated successfully,
rshooks build writes sethook.template.json:
{
"TransactionType": "SetHook",
"Account": "<ACCOUNT>",
"Hooks": [
{
"Hook": {
"CreateCode": "…hex of 0.govern.wasm…",
"HookOn": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFFBFFFFF",
"HookCanEmit": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF7FFFFFFFFFFFFFFFFFF9FFFFD",
"HookNamespace": "<NAMESPACE>",
"HookApiVersion": 0,
"HookName": "676F7665726E"
}
},
{
"Hook": {
"CreateCode": "…hex of 1.reward.wasm…",
"HookOn": "…",
"HookCanEmit": "…",
"HookNamespace": "<NAMESPACE>",
"HookApiVersion": 0,
"HookName": "7265776172D"
}
}
]
}
and sethook.template.meta.json:
{
"crate": "governance",
"version": "0.2.3",
"generated_at": "2026-08-18T09:00:00Z",
"hook_hashes": { "0": "…", "1": "…" },
"positions": { "declared": [0, 1], "gaps": [], "untouched_beyond": 2 },
"required_amendments": ["Hooks", "NamedHooks", "HookCanEmit"]
}
Generation rules, precisely:
-
Each declared index’s object key order is fixed:
CreateCode, thenHookOn(or theHookOnIncoming/HookOnOutgoingpair — whichever this entry declared, omitted entirely if this entry omitted its trigger),HookCanEmit(omitted if this entry omittedcan_emit; present, possibly deny-all, otherwise),HookNamespace,HookApiVersion(always0— chains are Guard-type only),HookParameters(only if this entry declares signature parameters — see below),HookName(only if this entry declared one), andFlags(only under--override, value1(hsfOVERRIDE), and only on declared, non-gap entries). -
A gap position is written as exactly
{"Hook": {}}— no keys at all, ever, since adding any (includingFlags) turns the no-op into a real operation. See Hook Chains for what that no-op does and doesn’t guarantee. -
Account/HookNamespaceare the literal placeholder strings shown above unless--account <r...>/--namespace <64hex>were passed at build time. -
HookParameters(installed parameter values) are never generated for an ordinary#[hook_param(...)]/#[otxn_param(...)]field — a hook parameter’s install-time value has no fixed representation in source (default = ...is a runtime fallback expression, not an encodable constant; see Hook and Transaction Parameters). Add aHookParametersentry to the template by hand if a position needs one of those installed. -
The one exception: an entry with signature-parameter fn arguments (the Hook Parameter Signature Interface, Hook and Transaction Parameters — requires the
unstable-param-sig-interfacefeature) does get a generatedHookParametersblock — one declaration entry per declared argument, in index order, each withHookParameterValue = "00"(the interface’s own placeholder for “this parameter exists, at this index, with this type” — not an installed value). Forexamples/19_param-signature’sincrement(account: AccountID, count: UInt16):"HookParameters": [ { "HookParameter": { "HookParameterName": "5F5053000008076163636F756E74", "HookParameterValue": "00" } }, { "HookParameter": { "HookParameterName": "5F505300010105636F756E74", "HookParameterValue": "00" } } ]An entry with no signature-parameter arguments omits the key entirely, exactly like before this feature — this is additive, not a change to the general rule above.
-
A second, independent exception: a chain declaring state interface fields (the Hook State Interface, Hook State — requires the
unstable-state-interfacefeature) gets one declaration entry per#[state_interface(..)]field on every non-gap entry — state is chain-level, shared by every entry, so the declarations aren’t tied to one particular entry the way signature parameters are. They’re appended after that entry’s own signature-parameter declarations, if any. Unlike a signature-parameter declaration,HookParameterValuehere carries the real value schema, not a"00"placeholder — see the book section linked above for the exact wire format. Forexamples/20_state-interface’sbalances(id=0)/config(id=1):"HookParameters": [ { "HookParameter": { "HookParameterName": "5F534900000208076163636F756E740205746F6B656E", "HookParameterValue": "020306616D6F756E74020775706461746564" } }, { "HookParameter": { "HookParameterName": "5F5349000100", "HookParameterValue": "011006706175736564" } } ]A chain with no
#[state_interface(..)]fields at all gets none of this — again additive, never a change to the general rule. -
sethook.template.meta.jsonis generation provenance, not part of the transaction to submit:hook_hashesmap index toHookHash;positionsrecords which indices are declared, which are gaps within that range, and how far past the highest declared index the account’s own chain is left untouched;required_amendmentsalways includesHooksand addsNamedHooks/HookOnV2/HookCanEmitonly when the template actually used the corresponding feature — it does not attempt to infer amendment requirements from what the wasm’s own Hook API calls need.
See The rshooks CLI for the --account/--namespace/
--override flags themselves, and Hook Chains
for the conceptual model (owned-position patch, fail-closed by default,
generation directories) this page’s JSON implements.
Off-Chain Unit Tests
Every other page in this book runs against a real Hook API — either the
live standalone node the end-to-end suite (docs/E2E-TESTING.md at the
repository root) deploys to, or, implicitly, the wasm host your Hook
eventually ships to.
This page covers a third option: rshooks-testenv, a mock host that runs a
#[hooks] chain entry as plain native Rust under cargo test — no wasm
build, no Docker, no node — with assertions on state changes, accept/rollback
exits, and emitted transactions.
Positioning: not a fidelity oracle
cargo test against rshooks-testenv answers one question fast: is my
hook’s logic right, in milliseconds, iterating faster than a wasm build
lets you. It is not a substitute for the end-to-end suite. Ledger
objects/keylets/slots, XFL float operations, and signature verification are
all in scope and modeled (see Hook API coverage
below); fee/reserve economics (explicit approximations), real instruction
metering, guard enforcement, HookOn chain routing/execution, and consensus
stay out of scope — see What this harness does not model
below for the complete, honest list. Treat the two suites as complementary:
reach for rshooks-testenv while you’re writing and refactoring hook logic,
and rely on the end-to-end suite to confirm the compiled artifact behaves
the same way on a real ledger.
Setup
rshooks-testenv is a separate crate; both it and rshooks’s testenv
feature belong in [dev-dependencies] — a hook crate ships no_std with
neither of them linked into its wasm artifact.
[lib]
crate-type = ["cdylib", "rlib"] # rlib only needed for tests/ integration tests
[dependencies]
rshooks = { version = "0.2.3", features = ["host-panic-handler"] }
[dev-dependencies]
rshooks = { version = "0.2.3", features = ["testenv"] }
rshooks-testenv = "0.2.3"
Declaring rshooks twice — once in [dependencies], once in
[dev-dependencies] with a different feature list — is intentional, not a
mistake: Cargo unifies the two into one feature set (host-panic-handler +
testenv, plus rshooks’s default panic-handler) for cargo test builds,
while a plain cargo build/cargo rustc --crate-type cdylib (what
rshooks build actually runs) never activates dev-dependencies at all, so
testenv never reaches the shipped wasm. crate-type = ["cdylib", "rlib"]
is only needed if you write tests in a separate tests/ directory (below);
an in-crate #[cfg(test)] module needs neither the extra crate-type nor a
separate pub export, just the chain struct itself being pub (which
every #[hooks] struct in this book already is).
Two layouts
rshooks-testenv supports either style, and a crate can use both at once:
tests/*.rsintegration tests treat the hook crate as an ordinary library dependency (use my_hook::MyChain;). This needs therlibcrate-type addition above, but the crate’s#![no_std]attribute is untouched — the library itself never changes.- An in-crate
#[cfg(test)] mod testsmodule lives directly insrc/lib.rs. Because#![no_std]and astd-using test module can’t coexist unconditionally, the attribute becomes#![cfg_attr(not(test), no_std)]:no_stdstays in force for every real build (including the wasm build —cfg(test)is never set there), and only thecargo testcompilation of the crate itself switches it off. A#[cfg(test)]module contributes nothing to any non-test build, wasm included, so this changes nothing about what ships.
examples/02_state-counter uses the tests/ layout
(examples/02_state-counter/tests/counter.rs); examples/10_emit-txn
demonstrates both side by side — examples/10_emit-txn/tests/emit.rs and an
in-crate module at the bottom of examples/10_emit-txn/src/lib.rs. Both
examples’ README.md show the exact cargo test invocation.
examples/16_typed-results/tests/deposit.rs uses the tests/ layout too,
and additionally asserts that a typed entry’s
?-propagated hook_errors! msg-clause message arrives byte-for-byte in
HookExit.msg on the rollback path — the same exit.msg assertion shown
below, just fed by Err(Rollback::new(..)) instead of a hand-written
rollback!(msg, code) call.
A worked example
This is examples/02_state-counter reduced to its #[hooks] declaration —
unchanged from every other page in this book:
#[hooks]
pub struct StateCounter {
#[state(key = b"counter")]
counter: State<u64>,
}
#[hooks]
impl StateCounter {
#[hook(0, on = [Invoke])]
fn main(&self) -> HookResult {
let count = self.state.counter.get().unwrap_or(Some(0)).unwrap_or(0);
let next = count.wrapping_add(1);
if self.state.counter.set(&next).is_err() {
rollback!(b"state-counter: state_set failed", StateCounterError::StateSetFailed);
}
Ok(Accept::new(b"state-counter: incremented", next as i64))
}
}
and the real test file that drives it, examples/02_state-counter/tests/counter.rs:
use rshooks_testenv::prelude::*;
use state_counter::{StateCounter, StateCounterError};
fn env() -> TestEnv {
TestEnv::new()
.hook_account([1u8; 20])
.otxn(Otxn::new(TxType::Invoke).account([2u8; 20]))
}
#[test]
fn first_invoke_counts_to_one() {
let env = env();
let exit = env.invoke::<StateCounter>(0);
assert_eq!(exit.exit, ExitType::Accept);
assert_eq!(exit.code, 1);
assert_eq!(env.state_typed::<u64>(b"counter"), Some(1));
}
#[test]
fn counter_persists_across_invocations() {
let env = env();
env.invoke::<StateCounter>(0);
env.invoke::<StateCounter>(0);
assert_eq!(env.state_typed::<u64>(b"counter"), Some(2));
}
#[test]
fn state_set_failure_rolls_back_without_persisting() {
// Cap the value size below the 8-byte `u64` write this hook always
// attempts, forcing `state_set` to fail so the hook's own rollback
// path runs.
let env = env().max_state_value_len(4);
let exit = env.invoke::<StateCounter>(0);
assert_eq!(exit.exit, ExitType::Rollback);
assert_eq!(exit.code, StateCounterError::StateSetFailed.code());
assert_eq!(env.state_typed::<u64>(b"counter"), None);
}
rshooks_testenv::prelude::* pulls in TestEnv, HookExit/ExitType,
Otxn, Grant, EmittedTxn/EmitAttempt/TraceLine, and
rshooks::decl::HookChainEntries — the trait invoke’s type parameter is
bound by, implemented automatically on every #[hooks] chain struct on a
non-wasm target. env() builds a fresh TestEnv per test (it consumes and
returns self, so every builder call before the first invoke is a plain
chain), and invoke::<StateCounter>(0) runs the entry declared
#[hook(0, ...)] directly, by index — see Direct-entry invocation
below for what “directly” means. The third test shows the general technique
for forcing a hook’s own failure branch deterministically: override a
TestEnv world limit (here, max_state_value_len) so the exact Hook API
call the hook makes fails, rather than trying to construct byte-level input
that happens to trigger it.
Assertions API tour
Every accessor below is a method on TestEnv, taking &self — World is
interior-mutable, so you never need a mut binding, even across multiple
invoke calls on the same env.
state(key) -> Option<Vec<u8>>/state_typed::<T>(key) -> Option<T>read this hook’s own state (own account, own namespace, or a namespace set viaTestEnv::own_namespace).Nonecovers both “no entry” and “the key itself is malformed” forstate;state_typedadditionally panics if the entry is present but fails to decode asT— a decode failure at assertion time is a test-author bug (the wrong value type), not something worth silently reporting as absence.emitted() -> Vec<EmittedTxn>is every transaction this env has committed viaaccept!, cumulative across everyinvokecall so far.EmittedTxn::blob()gives the raw bytes for byte-level assertions,EmittedTxn::hash()the hashemitreturned, andEmittedTxn::tx_type()the decodedTxType(see Emitting Transactions for the shape atxn_template!-built blob actually has).emit_attempts() -> Vec<EmitAttempt>is everyemitcall, successful or not, cumulative — including attempts made during an invocation that ultimately rolled back.EmitAttempt::outcomeisOk(())(also present inemitted()) orErr(EmitFailureReason)(NoReserve,ReserveExceeded, orInvalidBlob).traces() -> Vec<TraceLine>is everytrace/trace_numcall this env has seen, cumulative, captured rather than printed — inspect it explicitly in a test instead of scrolling terminal output.hook_again_requested() -> boolreflects whether the most recently committedinvoke/invoke_cbakcall requested to run again (viahook_again). A rolled-back or merely-returning invocation never commits, so it leaves this accessor exactly as it was before that invocation ran — it is not reset tofalse: if an earlier accepted invocation had set ittrue, a later rolled-back invocation (whether or not that one itself calledhook_again) still readstrueafterward.skip_directives() -> Vec<([u8; 32], u32)>is everyhook_skip(hash, flags)call from every accepted invocation so far, verbatim, in call order (flags == 0add,flags == 1delete) — this harness has no chain model, so nothing actually acts on a skip directive; it exists purely for asserting a hook calledhook_skipwith the arguments you expect.
Exit types
invoke returns a HookExit { exit: ExitType, code: i64, msg: Vec<u8> }.
HookExit::is_success() is true only for ExitType::Accept:
ExitType | Where it comes from | World effect | is_success() |
|---|---|---|---|
Accept | accept!(msg, code) | state writes and this invocation’s validated emissions are committed | true |
Rollback | rollback!(msg, code) | this invocation’s state snapshot is restored, its emissions discarded | false |
Return | a bare return code (no accept!/rollback!) | provisionally treated like Rollback (snapshot restored) | false |
ExitType::Return’s mapping is explicitly marked provisional in the
harness’s own doc comments: no live-node evidence yet pins the real
on-chain commit semantics of a bare return from a Hook entry (xahaud’s own
ExitType internally is UNSET/WASM_ERROR/ROLLBACK/ACCEPT, with no
documented committed semantics for a plain return). The harness picks the
conservative reading — a test can’t pass on a state write that production
might silently discard — and a differential end-to-end test exists
specifically to pin the real behavior; once it lands, this table (and the
harness’s mapping, if it turns out to need one) will be updated to match.
Until then, don’t write a hook that relies on a bare return committing
anything.
World builders
Every builder below is on TestEnv, consumes self, and returns Self —
call them before the first invoke. Everything they set is part of the
persistent world and survives across every invoke call on the same
TestEnv; only what a single invocation itself does (state modification
count, emit reserve, nonce budget, and so on) resets per call.
hook_account(acc)— this hook’s own account, read back byhook_account().hook_hash(hook_no, hash)— the hash of the hook installed at chain positionhook_no, read back byhook_hash(hook_no).hook_pos(pos)— this hook’s own position in its chain, read back byhook_pos(). Chains aren’t auto-executed in Phase 1 (everyinvokeruns exactly one entry), but a chain is still representable this way — useful together withhook_hash/grantfor testing foreign-write authorization logic that depends on which hook is currently running.hook_param(name, value)— a Hook API parameter attached to this hook, read back byhook_param(name).otxn(Otxn)— the originating transaction everyinvokecall sees.Otxn::new(tx_type)starts one with every field absent; chain.account(acc),.destination(acc),.amount_drops(drops),.param(name, value),.id(hash), or the general escape hatch.field_raw(sfield, bytes)for anysfXxxcode not covered by a dedicated method. Every field is stored as its raw value bytes — whatotxn_fieldwould actually write into a caller buffer, no STObject header, no VL length prefix.otxn_emitted(burden, generation)— marks the seeded otxn as itself an emitted transaction (seedsotxn_burden/otxn_generation); absent, the otxn models an ordinary non-emitted transaction (otxn_burden() == 1,otxn_generation() == 0).state_entry(key, value)— pre-seeds one of this hook’s own state entries (own account, own namespace).keymust be1..=32bytes.foreign_state_entry(ns, acc, key, value)— pre-seeds a state entry belonging to another(account, namespace).grant(target_account, ns, authorize)— models aHookGrantontarget_account’s ledger object, authorizing a hook matched byauthorize: Grantto write into(target_account, ns)viastate_foreign_set.Grant::hook_hash(hash)matches by the currently invoked position’s hook hash regardless of account,Grant::account(acc)by hook account regardless of hash,Grant::both(hash, acc)requires both, andGrant::any()is unconditional. Direction matters: a write to this hook’s own account never consults grants at all — grants only gate a write into another account’s namespace, exactly like the real Hook API. Matching is presence-only (no signature verification); anything deeper stays end-to-end territory.ledger_seq(seq)/ledger_time(t)/ledger_last_hash(hash)— the current ledger sequence, the previous ledger’s close time, and the previous ledger’s hash, read back byledger_seq()/ledger_last_time()/ledger_last_hash().ledger_last_hashdefaults to[0u8; 32]unless called.own_namespace(ns)— overrides this hook’s own default state namespace ([0u8; 32]unless called).max_state_value_len(n)— overrides the cap (bytes, default 256, matching xahaud’smaxHookStateDataSizeat state scale 1) on a single state value; a write over the cap fails, exactly like the real host — the technique the worked example above uses to force a deterministicstate_setfailure.base_fee_drops(drops)— overrides the per-drop base fee theetxn_fee_base/fee_baseapproximation multiplies by (default 10 drops; see Fees are an explicit approximation).ledger_object(keylet, sto)— seeds a ledger object at its 34-byte keylet, serialized assto— backsslot_set/ledger_keylet(see Slots and Ledger Objects).otxn_meta(sto)— seeds the current transaction’s metadata — backsmeta_slot.xpop(tx, meta)— seeds an XPOP’s(transaction, metadata)byte pair — backsxpop_slot.strict_can_emit(bool)— on by default: afterinvoke, asserts every transaction type this invocation committed toemitted()is one the invoked entry’s#[hook(.., can_emit = [..])]list declares, matching the real host’sHookCanEmitenforcement. A violation panics — a test-author assertion, not a Hook API error path. An entry with nocan_emitdeclaration is unrestricted (no check), while a declared-emptycan_emit = []rejects every emission — the same three-state distinction the SetHook metadata carries on-chain. Passstrict_can_emit(false)to opt out for a lower-fidelity test that deliberately emits outside the declaration.
Direct-entry invocation: no HookOn filtering
invoke::<C>(index) is a direct entry call: it runs the declared entry
at index unconditionally, even if the seeded Otxn’s transaction type
would never have triggered that entry on-chain (#[hook(.., on = [..])]’s
HookOn filtering is not evaluated at all in Phase 1). This is the one
place TestEnv::strict_can_emit matters — the entry’s own declarations are
still checked for can_emit, just not for on. Reproducing “does this
chain’s HookOn routing actually dispatch to the entry I expect” is
end-to-end territory; see Hook Chains for how
HookOn is computed from on = [..].
Callback (#[cbak]) invocation
invoke_cbak::<C>(index, outcome) runs the entry’s paired #[cbak(index)]
body directly, standing in for xahaud’s own post-application callback
dispatch — outcome is CbakOutcome::Success(txn) or
CbakOutcome::Failure(txn) for one of env.emitted()’s transactions,
mirroring the 0/1 the real wasm cbak(u32) argument carries for a
successfully-applied vs. failed emission; a #[cbak] fn that declares an
EmitOutcome argument sees EmitOutcome::Applied/EmitOutcome::EmitFailure
accordingly. otxn_burden/otxn_generation read straight off the emitted
transaction’s own EmitDetails fields in both cases (not incremented,
unlike etxn_burden/etxn_generation’s “next emission” derivation).
For the duration of the call, the otxn (otxn_field/otxn_type/otxn_id)
differs by outcome — exactly what a real callback sees: Success presents
the emitted transaction itself; Failure presents the ttEMIT_FAILURE
pseudo-transaction the real host applies instead, carrying
sfLedgerSequence (the env’s own ledger sequence), sfTransactionHash
(the emitted transaction’s hash), and the emitted transaction’s own
sfEmitDetails, with its own otxn_id distinct from the emitted
transaction’s hash. The swap is undone as soon as the call returns: a
later invoke on the same TestEnv sees the originally seeded otxn
again. Everything else about the call (fresh InvocationContext, world
snapshot, accept!/rollback!/return mapping) is identical to invoke.
let exit = env.invoke::<EmitTxn>(0);
assert!(exit.is_success());
let txn = env.emitted()[0].clone();
let cbak_exit = env.invoke_cbak::<EmitTxn>(0, CbakOutcome::Success(txn));
assert!(cbak_exit.is_success());
etxn_details (and therefore prepare) writes an EmitDetails.EmitCallback
field, holding this hook’s own hook_account, exactly when the entry that
performs the emit declares a #[cbak] body — matching xahaud’s own
hookCtx.result.hasCallback check. invoke_cbak panics if the transaction
in outcome has no EmitCallback at all (most commonly: it was emitted by
an entry with no #[cbak]), or if EmitCallback’s account or the
transaction’s EmitHookHash don’t match this TestEnv’s own
hook_account/seeded hash — either way, no genuine on-chain callback could
ever be dispatched for that transaction against the entry being invoked.
Also panics if the entry at index declares no #[cbak] body at all.
Hook API coverage
As of .claude/design/TESTENV_PHASE2_DESIGN.md’s final stage (P2-E), the
mock backend answers the entire extern.h Hook API surface a hook can
call, _g (guard enforcement) excepted:
| Family | Covered |
|---|---|
| State | state, state_set, state_foreign, state_foreign_set, and every as-int64 variant (state_u64, state_foreign_u64, …) |
| Originating transaction | otxn_field, otxn_type, otxn_id (its flags argument is accepted but ignored — the seeded Otxn::id is always returned as-is), otxn_param, otxn_burden, otxn_generation, otxn_slot |
| Hook identity | hook_param, hook_account, hook_hash(hook_no), hook_pos — hook_param silently truncates into a too-short buffer and reports the truncated length, mirroring xahaud’s own asymmetry with otxn_param (which returns TooSmall) |
| Control leftovers | hook_again (once per invocation, ALREADY_SET on repeat), hook_skip (add/delete directives, no chain model), hook_param_set (per-hook-hash overrides, precedence over seeded hook_params — see below) |
| Ledger | ledger_seq, ledger_last_time, ledger_last_hash, ledger_nonce, ledger_keylet |
| Fees | fee_base (constant) |
| Emission | etxn_reserve, etxn_fee_base, etxn_details, etxn_burden, etxn_generation, etxn_nonce, emit, prepare |
| Control | accept, rollback |
| Tracing | trace, trace_num, trace_float (captures the raw XFL i64 bit pattern as its 8-byte big-endian encoding, the same convention trace_num uses — not a decimal “mantissa*10^exponent” rendering; decode it yourself from TraceLine::data if a test needs the human-readable value) |
| Float (XFL) | the full float_* family (float_set, arithmetic, comparison, float_sto/float_sto_set, float_int, float_log, float_root, …) — see XFL: Decimal Floating Point |
| Slot | the full slot_* family plus otxn_slot/meta_slot/xpop_slot — see Slots and Ledger Objects |
| STO | sto_subfield, sto_subarray, sto_validate, sto_emplace, sto_erase |
| Util / Keylets | util_sha512h, util_accid, util_raddr, util_verify, util_keylet/util_keylet_buf, and all 26 typed keylet_* helpers plus their keylet_*_into out-param twins — see Keylets |
| Callbacks | invoke_cbak::<C>(index, outcome) runs a declared #[cbak(index)] body directly — see Callback invocation above |
hook_param’s override precedence (hook_param_set, P2-E) reduces
xahaud’s real chain-forward semantics — any later hook in the same chain
execution sees a param override an earlier one set — to this harness’s
explicit-invocation model: an override only takes effect on a separate,
later invoke/invoke_cbak call seeded with the same hook_pos/
hook_hash as the call that set it (it commits only on that earlier call’s
accept!), never within the same invocation that called hook_param_set.
See What this harness does not model below for the honest, shrunk list of what is genuinely out of scope even now that every family above is implemented.
What this harness does not model
Documented, not accidental — each of these stays end-to-end (or
rshooks build check) territory, honestly enumerated from the
implementation as of .claude/design/TESTENV_PHASE2_DESIGN.md’s final
stage (P2-E):
- Guard enforcement (
_g) andHookOnchain routing._gkeeps returning0natively, with or withouttestenv— guard correctness isrshooks build check’s job (see ThershooksCLI) and the end-to-end suite’s, not this harness’s. Chain execution andon = [..]trigger filtering are not evaluated at all — covered above. rshooks::raw(directrshooks_core::*calls) bypasses the mock entirely, for every family — not just the ones this book’s worked examples happen to use. The backend only intercepts the specific call sites listed incrates/rshooks/testenv-call-sites.txt(thershookswrapper layer’sapi/*.rsfunctions, plusxfl.rs/xfl_unchecked.rs’s own rawfloat_*operator call sites — both were bridged as of P2-E; a hook or helper crate reachingrshooks_coredirectly anywhere else keeps hitting the realNOT_IMPLEMENTEDhost stubs undertestenv, exactly as on any other native build). This is deliberate —rshooks_coreis documented elsewhere in this book as the project’s own WCE escape hatch, and that hatch stays real on native builds too, rather than silently gaining mock coverage the wasm build doesn’t have.- Statics outside
HookStaticare not reset between invocations. An ordinary hand-declaredstatickeeps its value across everyinvokecall in the same process, unlike the fresh-wasm-instance-per-invocation reality on-chain.HookStatic(see Emitting Transactions’s statics idiom) is the one pattern this harness does reset correctly: on native undertestenv,take()hands out a freshly leaked clone of the static’s pristine value once per invocation (not once per process), so aHookStatic-held template likeexamples/10_emit-txn’sTXNbehaves the same on the secondinvokecall as it did on the first. This is whyHookStatic’s payload type must implementClone— an unconditional requirement of the type, identical on every target, not something that only shows up undertestenv. - Fee/reserve economics are explicit approximations.
etxn_fee_base’s default responder isbase_fee_drops × etxn_burden, not a parse of the actual transaction blob through xahaud’s real ledger fee calculator, and there is no reserve/owner-count model at all. Overridebase_fee_dropsif a test needs a specific number, but don’t treat the result as the real on-chain fee. slot_set/ledger_keyletonly resolve 34-byte keylets. xahaud also accepts a bare 32-byte transaction hash for some slot operations; this harness treats that shape asDOESNT_EXISTrather than modeling a separate transaction-hash lookup table.- Amendment gates are assumed active. Every Hook API function behaves as if every amendment it depends on is already enabled — there is no per-amendment feature-flag model.
ExitType::Returnis provisional — covered above.- Real reserve/consensus economics and instruction metering are unmodeled entirely.
Where to go next
- Hook Chains covers
HookOn/can_emitand how a#[state(...)]field declared once is shared across every entry in a chain — background forTestEnv::strict_can_emitand thehook_hash/hook_posbuilders above. - Accept, Rollback, and Errors covers
accept!/rollback!/hook_errors!themselves — this page only covers how their outcomes surface throughTestEnv::invoke. - Emitting Transactions covers
txn_template!andHookStaticin full — background for the emission-capture assertions and theHookStaticreset behavior above. docs/E2E-TESTING.md(repository root) covers the live-node suite this page’s harness deliberately does not replace.
Macro Reference
A lookup table of every user-facing macro, derive, and attribute rshooks
exports. Each entry is a one-line purpose plus a minimal invocation sketch —
for the full grammar, worked examples, and edge cases, follow the link into
the tutorial chapter that covers it, or the macro’s own rustdoc in
crates/rshooks/src/lib.rs.
Chain declaration and entry points
| attribute | purpose | sketch |
|---|---|---|
#[hooks(description = "...")] on a struct | Declares this crate’s Hook chain — a container for shared #[state]/#[hook_param]/#[otxn_param] fields, no runtime instance. Exactly one per crate. | #[hooks] pub struct MyHook; |
#[hooks] on an inherent impl | Declares this chain’s entry points. Exactly one per #[hooks] struct, in the same module. | #[hooks] impl MyHook { .. } |
#[hook(<index>, ...)] | Inside a #[hooks] impl: declares one Hook entry at the given chain position (0..=9, required). The fn must return a type implementing the sealed EntryReturn trait — currently, only HookResult (paired with ? — see Accept, Rollback, and Errors), with accept!/rollback! still usable in the body as an escape hatch (they diverge, coercing to HookResult). Any other return type is a compile error naming EntryReturn. Extra ident: Type arguments after &self declare Hook Parameter Signature Interface parameters, decoded before the body runs — requires the unstable-param-sig-interface feature — see Hook and Transaction Parameters. Named args: name, on/on_incoming+on_outgoing, can_emit, description. | #[hook(0, name = "accept", on = [Invoke])] fn main(&self) -> HookResult { accept!() } / #[hook(0, on = [Invoke])] fn increment(&self, account: AccountId, count: u16) -> HookResult { .. } |
#[cbak(<index>)] | Pairs with a #[hook] at the same index; exports cbak for that index — the optional callback invoked when a transaction this entry emitted later settles. Index only — a #[cbak] fn takes &self plus at most one further argument, the callback outcome (EmitOutcome or a raw u32, populated from the host’s cbak(u32) argument); it cannot declare signature-parameter arguments (a compile error), since those apply to #[hook] only. Same EntryReturn-bound return type as #[hook]. | #[cbak(0)] fn my_cbak(&self, outcome: EmitOutcome) -> HookResult { accept!() } |
An entry’s return-type error (a return type that doesn’t implement EntryReturn) is reported twice per bad entry: once on that entry fn’s own -> Ty (or the fn name when the return type is omitted), and once on the #[hooks] attribute from the generated wrapper body. In a chain with several entries, each bad entry still gets its own pair.
Receivers on #[hook]/#[cbak] entries and impl helpers
A #[cbak] entry function requires &self, optionally followed by one
argument — the callback outcome (EmitOutcome or a raw u32). A
#[hook] entry function requires &self, optionally followed by
signature-parameter arguments (ident: Type, decoded before the body
runs, requires the unstable-param-sig-interface feature — see Hook and
Transaction
Parameters).
Either way, the entry always receives the chain declaration by shared
reference, even for a unit-struct or field-less chain with nothing to read
through it. A non-attributed helper declared inside the same #[hooks] impl accepts either no receiver or &self.
| receiver | entry (#[hook]/#[cbak]) | impl helper |
|---|---|---|
none (fn helper() -> ...) | no — diagnostic: “hook entry functions take &self — the chain declaration is passed by shared reference (it is zero-sized)” | yes |
&self (fn main(&self) -> HookResult) | yes — receives the chain’s single zero-sized static by shared reference; reach its fields as self.<field> | yes |
self / mut self / &'a self / self: T | no — diagnostic: “use &self — hook entrypoints receive the chain declaration by shared reference (it is zero-sized)” | no — same diagnostic |
&mut self / &'a mut self | no — diagnostic: “chain handles are zero-sized and immutable; ledger state is accessed through the handles, not by mutating the struct — use &self” | no — same diagnostic |
Code outside the annotated impl (a free function, another module) has no
self to borrow and reaches the same static by the struct’s own name
instead (MyHook.some_field) — see Anatomy of a
Hook.
See Anatomy of a Hook, Hook Chains, Per-Hook Attributes, and Emitting Transactions.
Control flow & exit
| 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, and ?-convertible into rshooks::exit::Rollback; an optional per-variant => b"msg" clause supplies that conversion’s message. | hook_errors! { pub enum E { BlockedAccount = 1 => b"blocked" } } |
exit_on_err! | Unwrap a Result<T, E: Into<i64>>, rolling back on Err. | let v = exit_on_err!(b"failed", check()); |
rshooks::exit::{Accept, Rollback, HookResult} | Typed entry-return types (not macros): HookResult is Result<Accept, Rollback>; Accept::new/Rollback::new take a message and code (Accept::from_code(code) / Rollback::from_code(code) for an empty message). | Ok(Accept::new(b"ok", 0)) / Err(Rollback::new(b"no", 1)) |
See Accept, Rollback, and Errors and Guards and Loops.
Data & typing
| macro/attribute | 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 } |
#[state(key = ...)] / #[state(key_by = ...)] | On a #[hooks] struct field of type State<V>: declares a hook-state entity — key + value pairing, with .get()/.set()/.update()/.delete() (and .at(args) for key_by). | #[state(key_by = DepositKey)] deposits: State<Deposit>, |
state_keys! | Declare an enum of hook-state keys, each variant its own real byte length. | state_keys! { enum DataKey { Counter, Balance(AccountId) } } |
#[hook_param(name = ...)] / #[hook_param(name_by = ...)] | On a #[hooks] struct field of type HookParam<V>: declares a Hook parameter (this hook’s own installed parameters) — name + value pairing, .get()/.get_or_default()/.get_required() (and .at(args) for name_by). | #[hook_param(name = b"CFG", default = Config::default())] config: HookParam<Config>, |
#[otxn_param(name = ...)] / #[otxn_param(name_by = ...)] | Identical grammar to #[hook_param], but reads the originating transaction’s parameters. | #[otxn_param(name = b"INS", required)] instruction: OtxnParam<Instruction>, |
#[state_interface(id = .., key(..), value(..))] | (unstable-state-interface) On a #[hooks] struct field of type State<VName>: declares a Hook State Interface entity — the macro generates struct VName from value(..) and a typed key encoder from key(..), sharing #[state]’s .get()/.set()/.update()/.delete()/.at(args) accessors. | #[state_interface(id = 0, key(account: AccountId), value(amount: u64))] balances: State<Balance>, |
See Hook State, Hook and Transaction Parameters, and Typed Data with Derives.
Compile-time literals
| 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 in one auto-assigned slot, rewritten in place per hop — 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
There’s no separate metadata macro — a chain’s descriptive and
SetHook-facing metadata is the #[hooks(description = ...)] struct
attribute plus each entry’s #[hook(<index>, name = ..., on = ..., can_emit = ..., description = ...)] arguments, listed under “Chain
declaration and entry points” above. rshooks build extracts them into a
per-entry sidecar JSON and a SetHook template — see Per-Hook
Attributes.
The Prelude
use rshooks::prelude::*; is the standard way to bring rshooks’s
ergonomic surface into scope. Nearly every code sample in this book starts
with it (usually paired with use rshooks::*; for the macros, which live at
the crate root rather than in prelude). This page lists exactly what it
re-exports, grouped by area, and the two things it deliberately leaves out.
The prelude is deliberately not a full glob of rshooks-core — the raw
crate’s api::* functions share names with rshooks’s own wrappers (both
define state, for instance), so pulling in the whole thing would create
ambiguity. Only the constant-only modules come through from the raw layer;
everything else is rshooks’s own typed surface.
API wrapper modules
Every function from these rshooks::api submodules, except the numbered
slot functions (see “Deliberate absences” below):
| 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) plus their 26 keylet_xxx_into out-param twins. |
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". The same module also has buf_cmp_20, a loop-free
160-bit big-endian ordering for two 20-byte buffers (e.g. two AccountIds)
— XRPL/Xahau’s “high”/“low” account ordering, used to canonicalize a pair of
accounts (picking the low/high side of a RippleState trustline keylet).
See Guards and Loops.
no_unroll
crate::no_unroll (re-exported at the crate root too) — routes a loop’s
induction variable through core::hint::black_box at its comparison, so
LLVM can no longer prove the trip count at compile time and keeps the loop
as one real loop construct instead of fully unrolling it. Only worth
reaching for when a small, provably-bounded outer loop wrapping a
guard!-protected inner loop would otherwise get fully unrolled at
opt-level = 3 — unrolling physically duplicates the inner loop, and the
guard checker (which walks compiled bytecode) then counts its worst-case
cost once per duplicate instead of once total. while no_unroll(i) < N { .. } in place of while i < N { .. }.
Convert traits
crate::convert::{FixedRead, FromBytes, ToBytes, TypedParamName} — the
traits #[derive(HookData)]/#[derive(HookKey)]/#[derive(ParamName)]/
#[derive(ParamValue)] implement, and the trait a #[hook_param(...)]/
#[otxn_param(...)] field’s name carries. See Typed Data with
Derives.
The decl module: struct field handle types
crate::decl::{State, HookParam, OtxnParam} — re-exported at the crate
root and here in the prelude, since these are the field types a
#[hooks] struct’s #[state]/#[hook_param]/#[otxn_param] fields are
written with (see Hook State and Hook and Transaction
Parameters). The rest of decl — StateEntry,
HookParamAt, OtxnParamAt (the handles .at(args) returns for a
keyed/named-family field) and the *Spec/HookChainEntries traits — is
the macro-generated side of the handshake, not re-exported here or at the
crate root; reach it at rshooks::decl::StateEntry etc. when a field’s own
.at(args) return type needs naming (e.g. in a helper function’s
signature).
HookError/Result
crate::error::{HookError, Result} — the Result<T, HookError> alias every
typed Hook API wrapper returns, including HookError::NotImplemented (what
a raw call returns on a host build). See Accept, Rollback, and
Errors.
Accept/Rollback/HookResult
crate::exit::{Accept, Rollback, HookResult} — the typed entry-return
types: HookResult is Result<Accept, Rollback>, the only return type a
#[hook]/#[cbak] entry may declare (-> i64 does not compile). The sealed
EntryReturn conversion trait those types compile through is not in the
prelude (or nameable at all outside its fully qualified path) — a hook
author never calls it directly. See “Typed entry returns:
HookResult”.
State functions
crate::state::{StateKeyEncode, TypedStateKey, state_delete, state_foreign_get, state_foreign_get_typed, state_foreign_set_loose, state_foreign_set_typed, state_foreign_update_loose, state_foreign_update_typed, state_get, state_get_typed, state_set_loose, state_set_typed, state_update_loose, state_update_typed} — the composite/typed hook-state layer a #[state(...)]
struct field’s accessors forward to, plus the _foreign twins for reading
another account’s state. See Hook State.
HookStatic
crate::static_cell::HookStatic — the safe, const-constructible,
take-once cell for templates and large buffers that should land in a wasm
data segment/BSS rather than be materialized by runtime stores. See Anatomy
of a Hook.
Typed read views
crate::views::ledger::LedgerEntryCommonFields and
crate::views::tx::{TransactionCommonFields, TransactionCommonSlotFields}
— the common-field traits every generated view (views::tx::Payment,
views::ledger::AccountRoot, …) implements, covering the fields every
transaction (sfAccount, sfFee, sfMemos, …) or every ledger entry
(sfLedgerEntryType, sfFlags, …) carries. Importing the prelude is
enough to call these common accessors on any view; the view types
themselves (one per transaction/ledger-entry format) live under
rshooks::views::{tx, ledger} and are not globbed into the prelude —
use rshooks::views::tx::Payment; explicitly. See rshooks::views’s own
module doc comment for the full model (originating-transaction vs.
slot-backed sources, soeREQUIRED/soeOPTIONAL field typing, the
active-amendments/all-amendments format tiers).
StoWriter
crate::sto_writer::StoWriter — the bounded, allocation-free writer for a
runtime-sized STObject/STArray (a transaction whose shape
txn_template! can’t describe at compile time, e.g. Remit’s sfAmounts).
See Emitting Transactions and
The StoWriter API.
LedgerEntryType
crate::ledger_entry_type::LedgerEntryType — the typed ledger-entry-type
enum (LedgerEntryType::AccountRoot, …), decoded from a ledger object’s
sfLedgerEntryType the same way TxType decodes a transaction’s type.
Hook Parameter Signature Interface (sig), behind unstable-param-sig-interface
crate::sig::{Blob, IssueBytes, SigName, SigParamType, hook_sig_param, otxn_sig_param, otxn_sig_param_opt} — re-exported in the prelude only when
the unstable unstable-param-sig-interface cargo feature is enabled on
rshooks; the sig module itself doesn’t exist in the crate at all
otherwise. This is the support layer behind #[hook(..)]’s signature-
parameter fn arguments — see Hook and Transaction
Parameters. (The
module also has a hook_sig_param_opt function, the hook_param twin of
otxn_sig_param_opt; it is not re-exported in the prelude, so reach it at
rshooks::sig::hook_sig_param_opt if needed.)
TxType
crate::tx_type::TxType — the typed transaction-type enum (TxType::Payment,
…), used by otxn_type and by #[hook(<index>, ...)]’s on/can_emit
lists. See Reading the Originating Transaction.
Types
crate::types::* — protocol value newtypes: AccountId, Hash, Keylet,
StateKey, NameSpace, Nonce, PublicKey, CurrencyCode,
IssuedAsset (a CurrencyCode + issuing AccountId pair), the STObject/
STArray/Amount/Issue/Opaque marker types SField<T> and
StoWriter’s field writers take, and the length constants (ACC_ID_LEN,
STATE_KEY_LEN, EMIT_DETAILS_MAX_LEN, …).
XFL / XFLUnchecked
crate::xfl::XFL and crate::xfl_unchecked::XFLUnchecked — the checked and
hot-path-unchecked decimal floating-point types. See XFL: Decimal Floating
Point.
The XFL! macro
rshooks_macros::XFL — re-exported here too, alongside the xfl::XFL
type of the same name. This is not a naming collision: a macro and a type
live in separate Rust namespaces, the same relationship std::Clone (trait)
and #[derive(Clone)] (macro) have.
Constant families
rshooks_core::{consts::*, lets::*, ls_flags::*, tts::*, tx_flags::*} — the
C-verbatim constant tables: KEYLET_*/COMPARE_* (consts), ltXxx
ledger-entry-type codes, lsfXxx ledger-entry flags, ttXxx
transaction-type codes, and tfXxx/asfXxx transaction/account flags. See
The Raw Layer for the full family list, including the ones not
re-exported here (sfcodes, error, backend).
Two deliberate absences
- The raw
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::lets | Every ltXxx ledger-entry-type code (ltACCOUNT_ROOT, ltHOOK, …) — what rshooks::ledger_entry_type::LedgerEntryType decodes and sfLedgerEntryType carries — from the vendored ledger_entries.macro. |
raw::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. |
raw::backend | #[doc(hidden)], native-only: the HostBackend trait rshooks-testenv’s mock host implements, plus install() to swap one in for the duration of a scope. An unstable internal contract between rshooks/rshooks-core/rshooks-testenv, not a stable public API — but reachable, and occasionally useful directly in a test that needs to stub one specific host call (float_sto, say) without pulling in the whole TestEnv model; see rshooks::sto_writer’s own testenv_tests module or examples/17_sto-writer’s in-crate tests for the pattern. |
Every constant module is @generated from the vendored xahaud headers under
crates/rshooks-core/vendor/xahaud-hook/ — not hand-maintained — so a name
or value here always matches the upstream C header it was generated from.
When a hook author actually needs it
Two situations pull a hook out of the typed rshooks::api/prelude surface
and down into raw:
- Const contexts needing a raw
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 and built
with rshooks-build, numbered in suggested reading order; every code
sample in this book is adapted from one of these. See the examples
README on GitHub
for the full index (with each example’s matching book chapter), build/test
commands, and the source-style rules every example follows.