Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Your First Hook

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

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

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

The source

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

#![no_std]

use rshooks::*;

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

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

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

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

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

#![no_std]

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

use rshooks::*;

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

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

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

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

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

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

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

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

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

accept!()

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

Building it

From the crate’s own directory:

rshooks build --out out

or from elsewhere, pointing at its manifest:

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

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

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

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

What lands in out/

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

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

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

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

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

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