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).