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

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 with Xxx::otxn() uses.
  • SlotSource — wraps an already-loaded SlotObject<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:

stepcallswhat
Payment::otxn()1otxn_type + one integer compare
amount()1otxn_field
destination_tag()1otxn_field
hook_account_buf()1hook_account
keylet_line_for_asset()1util_keylet
RippleState::from_keylet()4slot_set, then the sfLedgerEntryType check
line.flags()3slot_subfield + read + clear
keylet_account()1util_keylet
AccountRoot::from_keylet()4slot_set, then the sfLedgerEntryType check
transfer_rate()1 or 3absent: 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]:

tiermeaningdefaultactive-amendmentsall-amendments
activeactivated on Xahau mainnetyesyesyes
pendingsupported by xahaud, not yet activatedyesnoyes
dormantno activation prospect on Xahau mainnetnonoyes

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 STArray field gives back raw bytes or a SlotObject<STArray> handle (via a ..._slot accessor); iterate it with the slot API and wrap each element in a views::inner type by hand.
  • No builders. These are read views. Emitting a transaction is rshooks::txn’s and rshooks::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::otxn or rshooks::slot_obj directly.