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.