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

Accept, Rollback, and Errors

A Hook always terminates by calling into the host: either accept, which keeps the originating transaction’s effects, or rollback, which discards them. This page covers rshooks’s Result/HookError type for Hook API failures, the accept!/rollback! macros that actually end execution, and hook_errors!/exit_on_err!, the idiom this crate provides for giving a hook its own meaningful, stable error-code system instead of one undifferentiated rollback code everywhere.

HookError and Result

Every Hook API function returns an i64: a non-negative value is a success payload (often a byte count, or a slot/field-pointer value), and a negative value is one of 45 documented error codes from the Hook API. rshooks decodes that negative range into a typed enum, rshooks::error::HookError:

use rshooks::error::HookError;

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

Every wrapper in rshooks::api::* and rshooks::xfl returns rshooks::error::Result<T> — a plain type alias for core::result::Result<T, HookError> — so a failed host call surfaces as an ordinary Err(HookError::SomeVariant) you can match on, rather than a raw negative integer. HookError::Unknown(i64) exists for forward compatibility, carrying the raw code for any negative value this version of the crate doesn’t yet recognize by name.

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

Ending execution: accept! and rollback!

A hook must always end by calling one of these two macros. Both accept the same two grammars:

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

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

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

Both macros expand to a call that, on the real wasm host, never returns — execution unwinds immediately. That’s why hooks written against these macros don’t need a placeholder return value on the branches that call them: rollback! has return type ! (the never type), so it type-checks against whatever the surrounding match/if arm needs to produce.

Designing meaningful error codes with hook_errors!

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

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

use rshooks::hook_errors;

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

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

  • impl From<RejectReason> for i64;
  • an inherent fn code(self) -> i64 — the same conversion as a method, for call sites that prefer err.code() over i64::from(err).

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

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

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

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

#[hook]
fn my_hook() -> i64 {
    if otxn_field_typed(sfAccount).is_err() {
        RejectReason::BadAccountField.rollback();
    }

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

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

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

    accept!()
}

Because RejectReason::rollback returns !, each match/if arm that calls it type-checks against whatever the other arms return — no placeholder value needed anywhere in the chain.

How the codes surface on-ledger

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

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

exit_on_err!: converting a Result at the boundary

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

use rshooks::{exit_on_err, hook_errors};

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

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

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

exit_on_err!(msg, result) expands to a match: Ok(value) evaluates to value, and Err(err) calls rollback!(msg, err) — which, on the real wasm host, never returns. E needs only Into<i64>, which every hook_errors! enum provides automatically (a plain i64 error works too, via the reflexive From<i64> for i64). This is the same “convert at the boundary” shape accept!/rollback! already use for their own code argument — ordinary helper functions stay in Result-land, and only the call site that actually needs to end the hook touches rollback! directly.

Where to go next