Change tmp buffer to output and make it global

This commit is contained in:
Sergey Kuznetsov
2026-08-03 12:53:43 +01:00
parent ef0b5dd1ac
commit e484a2902c
7 changed files with 341 additions and 106 deletions

View File

@@ -129,21 +129,20 @@ fn memory(caller: &Caller<'_, VmState<'_>>) -> Result<Memory, HostError> {
caller.data().memory.ok_or(HostError::NoMemExported)
}
/// Bounds-check `[ptr, ptr + len)` and return a `&[u8]` aliasing guest linear
/// memory — no allocation, no copy. The slice borrows `caller`, so it lives only
/// as long as the host call it feeds; host functions never re-enter the guest and
/// move its memory.
/// Bounds-check `[ptr, ptr + len)` against `data` and return that slice of it —
/// no allocation, no copy.
///
/// Checks params validity then the [`MAX_FIELD_BYTES`] cap
/// (`DataFieldTooLarge`), in that order, before the slice is formed. The transfer
/// budget is not among them: there are no copied bytes to charge, which is why
/// C++ left plain slice/string reads (`trace`'s msg/data, `sha512_half`'s input)
/// free of it — see [`charge_transfer`].
pub(crate) fn read_borrowed<'a>(
caller: &'a Caller<'_, VmState<'_>>,
ptr: i32,
len: i32,
) -> HostResult<&'a [u8]> {
/// Checks the params, then the [`MAX_FIELD_BYTES`] cap (`DataFieldTooLarge`),
/// then the bounds, before the slice is formed — C++'s `getDataSlice` order
/// (`HostFuncWrapper.cpp:150-176` at `b7059deb9f^`). The transfer budget is not
/// among them: there are no copied bytes to charge, which is why C++ left plain
/// slice/string reads (`trace`'s msg/data, `sha512_half`'s input) free of it — see
/// [`charge_transfer`].
///
/// Takes the memory's bytes rather than a [`Caller`], so a call can borrow as many
/// input regions as its signature has: they are shared borrows of one slice.
/// [`scratch_write`] is what supplies that slice.
pub(crate) fn region(data: &[u8], ptr: i32, len: i32) -> HostResult<&[u8]> {
// A guest's pointer and length are `i32` on the wire and indices here, so the
// conversion is the validity check: it fails on exactly the negative values.
let (Ok(ptr), Ok(len)) = (usize::try_from(ptr), usize::try_from(len)) else {
@@ -153,10 +152,21 @@ pub(crate) fn read_borrowed<'a>(
return Err(HostError::DataFieldTooLarge);
}
let end = ptr.checked_add(len).ok_or(HostError::PointerOutOfBounds)?;
memory(caller)?
.data(caller)
.get(ptr..end)
.ok_or(HostError::PointerOutOfBounds)
data.get(ptr..end).ok_or(HostError::PointerOutOfBounds)
}
/// [`region`] of the guest's linear memory, for a call that reads it and writes
/// nothing back (`trace`, `trace_num`).
///
/// The slice borrows `caller`, so it lives only as long as the host call it feeds;
/// host functions never re-enter the guest and move its memory.
pub(crate) fn read_borrowed<'a>(
caller: &'a Caller<'_, VmState<'_>>,
ptr: i32,
len: i32,
) -> HostResult<&'a [u8]> {
let mem = memory(caller)?;
region(mem.data(caller), ptr, len)
}
/// Service a "fill-the-caller's-buffer" host call: bounds-check the guest output
@@ -236,55 +246,92 @@ pub(crate) fn write_into(
Ok(n)
}
// The input buffer in `read_write` lives on the stack, sized to the field cap.
// Guard the assumption that the cap stays small enough for that to be fine.
const _: () = assert!(
MAX_FIELD_BYTES <= 8 * 1024,
"read_write's input buffer is a stack array; keep MAX_FIELD_BYTES small"
);
/// Service a host call that reads one region of guest memory and writes another
/// (e.g. `sha512_half`).
/// Service a host call that reads guest memory and writes a value back into it
/// (`sha512_half`): the host fills the run's scratch buffer
/// ([`VmState::scratch`](crate::vm::VmState::scratch)), and the engine copies the
/// result into the guest's output region once every rule has passed.
///
/// The input is copied into a stack buffer bounded by [`MAX_FIELD_BYTES`], so it
/// stays valid while [`write_into`] borrows guest memory mutably for the output —
/// no aliasing reasoning, at the price of zero-filling the buffer each call. The
/// copy is host-private scratch rather than a value crossing the boundary, so it
/// costs no transfer budget, as C++'s `sha512_half` input did not
/// ([`charge_transfer`]). The output half is [`write_into`], so it obeys the same
/// policy as a plain write — including the charge.
pub(crate) fn read_write(
/// `call` is handed the guest's whole linear memory, so it borrows **as many**
/// input regions as it needs with [`region`]. That is the difference from
/// [`write_into`]: a `&mut` view of guest memory admits no simultaneous `&` view,
/// so a host writing straight into the guest can only be given inputs that were
/// copied out first, one buffer per input. Reading many and writing one is the
/// common shape in this ABI — the two-argument keylets, the float arithmetic ops —
/// and it is this helper that generalizes to it.
///
/// **The host is never told the guest's capacity.** It is offered the whole
/// [`MAX_FIELD_BYTES`] scratch, and reports the value's true length; the fit is
/// decided here. Two consequences worth the indirection:
///
/// - Nothing reaches guest memory until the length, the bounds, the fit and the
/// budget have all passed, so a refused call cannot leave part of a value in the
/// guest's buffer. [`write_into`] can only bound what is *writable*.
/// - The checks then run in C++'s `setData` order — params, cap, bounds, fit,
/// transfer, copy (`HostFuncWrapper.cpp:115-148` at `b7059deb9f^`) — *after* the
/// value exists, which is the order C++ could use for exactly this reason.
///
/// So the output region is validated after the inputs, and a call with both bad
/// reports the input's verdict, as C++'s `getDataSlice`-then-`setData` sequence
/// did. `NoMemExported` is the one verdict that precedes both: it is a fact about
/// the instance rather than about this call's arguments, and there is no memory to
/// validate a region against.
pub(crate) fn scratch_write(
caller: &mut Caller<'_, VmState<'_>>,
src: i32,
src_len: i32,
dst: i32,
cap: i32,
call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8]) -> HostResult<usize>,
) -> HostResult<i32> {
// As in `read_borrowed`: the conversion to an index is the validity check.
let (Ok(src), Ok(len)) = (usize::try_from(src), usize::try_from(src_len)) else {
let mem = memory(caller)?;
// One borrow, split in two: the guest's bytes, which the inputs are slices of,
// and the store data holding the scratch the output goes to. Taking them
// together is what keeps the inputs borrowed instead of copied.
let (data, state) = mem.data_and_store_mut(&mut *caller);
// `&dyn HostFunctions` is `Copy` and outlives the store data, so taking it here
// does not hold a borrow of `state` across the call below.
let host: &dyn HostFunctions = state.host;
let n = call(host, data, &mut state.scratch[..])?;
// As in `region`: the conversion to an index is the validity check.
let (Ok(dst), Ok(cap)) = (usize::try_from(dst), usize::try_from(cap)) else {
return Err(HostError::InvalidParams);
};
if len > MAX_FIELD_BYTES {
// `n` is the value's true length, which `call` reports whether or not it wrote
// it, so it is bounded by neither the scratch nor the guest's buffer. This is
// how the guest learns a value was too large rather than merely unwritten.
if n > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
}
// Copy the input out before `write_into` borrows guest memory mutably.
let mut buf = [0u8; MAX_FIELD_BYTES];
memory(caller)?
.read(&*caller, src, &mut buf[..len])
.map_err(|_| HostError::PointerOutOfBounds)?;
let input = &buf[..len];
write_into(caller, dst, cap, |host, out| call(host, input, out))
// The guest's whole declared region, so a buffer running past memory is its
// pointer being wrong rather than a truncated prefix of it being served.
let end = dst.checked_add(cap).ok_or(HostError::PointerOutOfBounds)?;
let out = data
.get_mut(dst..end)
.ok_or(HostError::PointerOutOfBounds)?;
if n > cap {
return Err(HostError::BufferTooSmall);
}
charge_transfer(state, n)?;
out[..n].copy_from_slice(&state.scratch[..n]);
// The cap check above bounds `n`, so the count reaches the wire whole: an
// `i32` the guest reads as a byte count, never a truncation of a larger one.
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32"
)]
let n = n as i32;
Ok(n)
}
// ---------------------------------------------------------------------------
// Unit tests
//
// A `Caller` exists only for the duration of a host call, so `read_borrowed`,
// `write_into`, `read_write` and `memory` are unreachable from here; `tests/`
// covers them by running real modules against a fake host.
// `write_into`, `scratch_write` and `memory` are unreachable from here; `tests/`
// covers them by running real modules against a fake host. `region` is the
// exception, taking bytes rather than a `Caller`, but the same tests reach it
// through every call that borrows an input.
// ---------------------------------------------------------------------------
#[cfg(test)]
@@ -323,6 +370,7 @@ mod tests {
mem_limits: StoreLimitsBuilder::new().build(),
transfer_budget: Cell::new(budget),
memory: None,
scratch: [0u8; MAX_FIELD_BYTES],
}
}

View File

@@ -1,4 +1,4 @@
use crate::abi::{charged, read_borrowed, read_write, write_into};
use crate::abi::{charged, read_borrowed, region, scratch_write, write_into};
use crate::vm::VmState;
use wasmi::{Caller, Linker};
use xrpl_host_functions::{HostError, HostFunctionSpec};
@@ -75,17 +75,14 @@ pub(crate) fn register_host_functions(
out_len: i32|
-> Result<i32, wasmi::Error> {
charged(&mut caller, HostFunctionSpec::Sha512Half, |c| {
// Input copied into a stack buffer (no heap), output
// written straight into guest memory; `read_write`
// owns the read/write bounds/cap/transfer policy.
read_write(
c,
data_ptr,
data_len,
out_ptr,
out_len,
|host, data, out| host.sha512_half(data, out),
)
// The input is borrowed straight out of guest memory and
// the digest is written to the run's scratch, which
// `scratch_write` copies to the guest after its
// bounds/cap/buffer/transfer policy passes.
scratch_write(c, out_ptr, out_len, |host, data, out| {
let input = region(data, data_ptr, data_len)?;
host.sha512_half(input, out)
})
})
},
),

View File

@@ -73,6 +73,22 @@ pub(crate) struct VmState<'h> {
/// then serve a host call against the wrong instance's memory, which is a wrong
/// answer rather than an error anyone sees.
pub(crate) memory: Option<Memory>,
/// Where a host writes a value before the engine copies it to the guest, for
/// the calls that read guest memory and write it in the same breath
/// ([`crate::abi::scratch_write`]).
///
/// One buffer per run, reused by every call, so no call zero-fills one of its
/// own. Sized to [`MAX_FIELD_BYTES`], which is what lets a host be offered the
/// whole cap and report the value's true length while the fit against the
/// guest's buffer is decided afterwards — with nothing yet in guest memory.
///
/// Inline rather than boxed: the store's data is built once per run and then
/// only borrowed, so a kilobyte in it costs one move at construction, where a
/// `Box` would cost an allocation. A local in
/// [`scratch_write`](crate::abi::scratch_write) would cost neither, but
/// `forbid(unsafe_code)` means a stack buffer is zero-filled, and that lands
/// back on every call — which is the cost this field exists to remove.
pub(crate) scratch: [u8; MAX_FIELD_BYTES],
}
/// Outcome of running an escrow contract to completion.
@@ -300,6 +316,7 @@ pub fn run<'h>(
mem_limits,
transfer_budget: Cell::new(TRANSFER_LIMIT_BYTES),
memory: None,
scratch: [0u8; MAX_FIELD_BYTES],
},
);
// A store that will not take fuel, or imports that will not register, are

View File

@@ -389,9 +389,9 @@ fn reads_do_not_spend_the_transfer_budget() {
}
/// Only the output half of a read-write call spends the budget. `sha512_half`'s
/// input is a borrowed read like any other — the stack copy `read_write` takes is
/// host-private scratch, not a value crossing the boundary so a run may hash far
/// more bytes than the budget holds as long as the digests it writes fit inside it.
/// input is a borrowed read like any other, aliasing guest memory rather than
/// crossing the boundary, so a run may hash far more bytes than the budget holds as
/// long as the digests it writes fit inside it.
///
/// The two totals are asserted, so the arithmetic that makes the case is in the
/// test rather than in a comment: the inputs alone would overrun the budget, the

View File

@@ -294,11 +294,18 @@ fn both_of_traces_regions_are_checked() {
}
// ---------------------------------------------------------------------------
// Both at once (`read_write`, via `sha512_half`)
// Both at once (`scratch_write`, via `sha512_half`)
// ---------------------------------------------------------------------------
/// A call with an input and an output region checks the input first, so a bad
/// input is reported even when the output region is also bad.
/// A call with an input and an output region decides everything about the input
/// before anything about the output, so a bad input is reported however the output
/// region is wrong — out of bounds, or a pointer that is not one at all.
///
/// The whole output region, params included, is judged after the host has answered,
/// which is `getDataSlice`-then-`setData` (`HostFuncWrapper.cpp:115-176` at
/// `b7059deb9f^`). Hoisting any part of it above the call would put the output's
/// verdict first for these cases, and there is no half of it that can be hoisted on
/// a principle the other half shares.
#[test]
fn a_read_write_checks_its_input_before_its_output() {
let host = FakeHost::new();
@@ -321,9 +328,16 @@ fn a_read_write_checks_its_input_before_its_output() {
code(HostError::PointerOutOfBounds)
);
// A bad input and a bad output: the input's verdict is the one reported.
let both_bad = digest(0, OVER_CAP, PAGE);
assert_eq!(status(&both_bad, &host), code(HostError::DataFieldTooLarge));
// A bad input against each way the output can be wrong: the input's verdict is
// the one reported, and the host is never asked for a value nobody can take.
for dst in [PAGE, -1] {
let both_bad = digest(0, OVER_CAP, dst);
assert_eq!(
status(&both_bad, &host),
code(HostError::DataFieldTooLarge),
"dst {dst}"
);
}
assert!(host.digested.borrow().is_empty(), "the host is not reached");
}
@@ -347,10 +361,57 @@ fn a_read_write_output_obeys_the_write_rules() {
assert_eq!(status(&wat, &host), code(HostError::PointerOutOfBounds));
}
/// An input region may overlap the output region: the engine copies the input out
/// of guest memory before the host writes back into it. The marker is any byte
/// distinct from the input's first (`a`), so `finish` returning it proves the write
/// landed.
/// A refused value reaches guest memory in no part, however much of it the host
/// wrote. The host answers with 32 bytes it did write and a length it did not, so
/// the refusal happens with the value sitting in the run's scratch — and the
/// guest's buffer has to come back untouched.
///
/// Stronger than the contract asks for: a guest must not read its buffer on a
/// negative status. It holds because the scratch is copied to the guest only after
/// the length, the bounds, the fit and the budget have all passed, so there is no
/// window in which a refused value is in guest memory.
#[test]
fn a_refused_value_leaves_nothing_in_guest_memory() {
const MARKER: u8 = 77;
// The two refusals a value can meet after the host has produced it: longer
// than the field cap, and longer than the buffer the guest offered.
let refusals = [
(MAX_FIELD_BYTES + 1, HASH_LEN, HostError::DataFieldTooLarge),
(HASH_LEN, HASH_LEN - 1, HostError::BufferTooSmall),
];
for (claimed, cap, expected) in refusals {
let host =
FakeHost::new().answering_digest(Answer::writing_but_claiming([MARKER; 32], claimed));
let call = format!(
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 64) (i32.const {cap}))"
);
let refused = module(&[import::SHA512_HALF, ONE_PAGE], &call);
assert_eq!(
status(&refused, &host),
code(expected),
"claiming {claimed}"
);
// The same call, reporting what is at the output region afterwards.
let inspect = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!("(drop {call}) (i32.load8_u (i32.const 64))"),
);
assert_eq!(
status(&inspect, &host),
0,
"claiming {claimed}: the refused value must not have been written"
);
}
}
/// An input region may overlap the output region: the host is served the input as
/// it stands and its answer lands afterwards, so the two cannot interfere. The
/// marker is any byte distinct from the input's first (`a`), so `finish` returning
/// it proves the write landed.
#[test]
fn an_input_may_overlap_the_output() {
const MARKER: u8 = 99;
@@ -411,6 +472,27 @@ fn a_module_that_exports_no_memory_cannot_call_the_host() {
assert_no_memory(&wat, &host);
}
/// Having no memory is answered before anything about a call's arguments, so a
/// module without one ends the run even when its arguments would have earned a
/// guest-visible code of their own (here an input over the field cap).
///
/// The order is deliberate: no memory is a fact about the instance, not about this
/// call, and a region cannot be validated against a memory that is not there. It
/// costs the guest nothing — every call such a module makes ends the run anyway.
#[test]
fn no_memory_is_answered_before_a_calls_arguments_are() {
let host = FakeHost::new();
let wat = module(
&[import::SHA512_HALF, "(memory 1)"],
&format!(
"(call $sha512_half (i32.const 0) (i32.const {OVER_CAP})
(i32.const 0) (i32.const {HASH_LEN}))"
),
);
assert_no_memory(&wat, &host);
}
/// The memory's export *name* is not part of the contract: the engine takes the
/// module's memory whatever it is called. Nothing in the wasm spec attaches meaning
/// to `"memory"` — it is a toolchain convention — and C++ read no name either

View File

@@ -55,6 +55,16 @@ impl Answer {
}
}
/// Writes `bytes` and reports `len` regardless — a host whose value is longer
/// than what it put in the buffer, which the engine has to refuse without
/// letting those bytes reach the guest.
pub fn writing_but_claiming(bytes: impl Into<Vec<u8>>, len: usize) -> Answer {
Answer::Value {
bytes: bytes.into(),
len,
}
}
/// `len` bytes counting up from 0, written and reported.
pub fn filler(len: usize) -> Answer {
Answer::bytes((0..len).map(|i| i as u8).collect::<Vec<u8>>())

View File

@@ -337,12 +337,42 @@ question, not an engine defect**, and it should carry much less weight in the de
than the paragraph above once implied. Judge C11 mainly on the cost table and on
`read_write` not generalizing past one byte input.
### Status: this is the open decision (2026-07-30)
### Resolved: the scratch owns the *output*, and only where there is an input (2026-08-03)
The VM compiles and works, so the reason this was deferred is spent, and C10 has landed —
so the per-call export lookup both designs would otherwise pay is already gone. **This is
now the next thing on the list and it needs answering before C11 is codeable.** The typed
shims, generated header and probe-module test stay deferred.
**Decided and landed with C11: the buffer moves to the output side, and `write_into`'s
direct path stays for the calls that have no byte input.** The cost table above framed
this as one-or-the-other, and that framing is what made it look like a wash — it is not,
because the row scratch makes worse is exactly the row that does not need scratch. A
function with no byte input has no borrow conflict to resolve.
What settled it is a census of the real ABI rather than the two example rows. Classifying
all 65 registrations in `setCommonHostFunctions` (plus `set_data`) by shape, from the
recovered `HostFuncWrapper.h` protos:
| shape | count | helper |
| --- | --- | --- |
| ≥1 byte input **and** a byte output | **38** | `scratch_write` |
| byte output only | 9 | `write_into`, unchanged |
| byte inputs only, or scalars | 18 | `read_borrowed` / `region`, unchanged |
The 38 are 16 one-in/one-out, 18 two-in/one-out, 3 three-in/one-out (`credential_id`,
`trustline_id`, `paychan_id`), and one **one-in/two-out**: `float_to_mant_exp`, which
writes mantissa and exponent into separate guest regions and is the function behind
interop question 5. So **22 of the 38 cannot be expressed by a one-input helper at all**,
and they are the bulk of the ABI's substance — every two-argument keylet, `nft_uri`, and
all four float arithmetic ops. Under the old shape they need `read2_write`, `read3_write`
and `read_write2`; under this one they are all the same call. That, not the memset, is
what the finding was really about.
`MaybeUninit` was considered and rejected, and the reason is not squeamishness about
`unsafe`: it does not reach the memset from either side. `Memory::read` wants an
initialized `&mut [u8]` and wasmi 1.1 has no `read_uninit`, and the `HostFunctions`
trait's out-param is `&mut [u8]`, so an uninit output region would push `unsafe` into
every host impl including the C++ adapter. A **per-run** buffer gets the same win with no
`unsafe` at all — one 1 KiB fill per run instead of one per call, so there is nothing
left for `MaybeUninit` to remove. `#![forbid(unsafe_code)]` (D14) stays.
The typed shims, generated header and probe-module test stay deferred.
## Open ABI questions and interop risks (2026-07-29)
@@ -647,11 +677,62 @@ same one-instance-per-run assumption C10's cache would take on.
as `"mem"` *beside* a global named `"memory"`, asserting the call succeeds — which
states the rule in both directions: the conventional name neither qualifies a
non-memory nor hides the real one.
11. `read_write` memsets 1 KiB of stack per call and does not generalize past one byte
input — that is the scratch-buffer decision already open above ("Open: where the
output region points"), which is now the live one and needs answering before this is
codeable. #10 makes either choice easier. Note A4 spent that section's
leaves-bytes-behind argument; read the amendment there before deciding.
11. ✓ **`read_write` memsets 1 KiB of stack per call and does not generalize past one byte
input.** That was the scratch-buffer decision above; see "Resolved: the scratch owns
the *output*" for the census that decided it and for why `MaybeUninit` is not the
answer.
`read_write` is gone, replaced by `scratch_write`, and the input primitive split in
two: `region(data, ptr, len)` does the validation and slicing against a plain `&[u8]`,
and `read_borrowed` is now that over the guest's memory for the calls that read
without writing. Taking bytes rather than a `Caller` is the whole trick — input
regions become shared borrows of one slice, so a call takes as many as its signature
has.
**The borrow conflict dissolves rather than being worked around, and that is what
made this cheap.** `Memory::data_and_store_mut` (`memory/mod.rs:165`) returns
`(&mut [u8], &mut T)` — the guest's bytes and the store data in one split borrow. So
the inputs are borrowed from guest memory *while* the host writes the scratch that
lives in the store data, with no take-and-put-back, no `Cell`, and no `unsafe`. It
compiled unchanged on the first attempt.
Three things fell out that are worth more than the memset:
- **A4's residual is closed structurally.** The host is never told the guest's
capacity — it gets the whole `MAX_FIELD_BYTES` scratch and reports the value's true
length — so nothing reaches guest memory until the length, bounds, fit and budget
have all passed. `a_refused_value_leaves_nothing_in_guest_memory` pins it, and a
mutation that copies eagerly fails exactly that test and no other. It is the
*under-the-cap* case that bites, the one the clamp could not reach.
- **The check order is now C++'s `setData` order** — params, cap, bounds, fit,
transfer, copy (`HostFuncWrapper.cpp:115-148` at `b7059deb9f^`) — *after* the value
exists. C++ could use that order because it had a scratch (`std::expected<Bytes>`
then `setData`); `write_into` cannot, since it must bounds-check before handing over
a slice. Input validation still precedes all of it, as
`getDataSlice`-then-`setData` did.
- **One accepted behaviour change**: `NoMemExported` now precedes a call's argument
validation, because the memory has to be resolved before there are bytes to validate
a region against. C++ checked the input's cap first. It costs a guest nothing — a
module with no memory export cannot serve any host call — and
`no_memory_is_answered_before_a_calls_arguments_are` makes it a decision rather than
an accident.
The memset half, for the record, was probably never the cost it looked like: the
scratch is one per-run buffer, so no call fills one, but `sha512_half` is 2000 gas and
a 1 KiB fill is tens of nanoseconds. The generalization was the finding.
**The scratch field is inline, not boxed** — the store's data is built once per run and
then only borrowed, so a kilobyte in it costs one move where a `Box` costs an
allocation. **Lazy init is deferred to a benchmark, not rejected.** `Option<[u8; N]>`
with `get_or_insert_with` is the shape (not `OnceCell`, which is for init behind a
shared borrow; `scratch_write` holds `&mut VmState`), and the case against it today is
a magnitude argument that a measurement could overturn: it defers one ~1 KiB fill per
run — invisible beside the `Module::new` that starts every run — and pays for it with a
discriminant test on every host call, which is the direction C11 was moving cost away
from. `Option<[u8; N]>` also does not shrink `VmState` (no niche in a byte array, so
1025 bytes), and `Option<Box<[u8; N]>>` does but then charges a malloc to the 38
functions that use this path in order to save the ones that do not. Revisit with the
google-benchmark harness, where a host-call-heavy module can price the per-call branch
against the per-run fill.
12. `Linker` is rebuilt per `run` (five `func_wrap`s plus string interning) and the
module is compiled per run with no cache. Lower priority. The blocker worth
recording: `VmState<'h>`'s lifetime forces `Linker<VmState<'h>>` to be per-run —
@@ -798,26 +879,28 @@ useful for comparison and for the gas assertions in `Wasm_test.cpp` — not gosp
**`crates/` compiles**, and the whole workspace is green — `cargo test --workspace`,
`clippy --workspace --all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`
(which `deny(rustdoc::broken_intra_doc_links)` now makes load-bearing). 123 tests: 33
macro, 12 facade, 1 doctest, and **77 in `xrpl-wasm-vm`** (10 unit; 67 integration — 12
`host_calls`, 21 `memory_policy`, 13 `budgets`, 21 `vm_limits`).
(which `deny(rustdoc::broken_intra_doc_links)` now makes load-bearing). 125 tests: 33
macro, 12 facade, 1 doctest, and **79 in `xrpl-wasm-vm`** (10 unit; 69 integration — 12
`host_calls`, 23 `memory_policy`, 13 `budgets`, 21 `vm_limits`).
**Thirteen of the seventeen findings are closed** (2026-07-30): all of section A, all of
B, D13D15, two thirds of D16, and C10. What is left is **C11**, blocked on the
scratch-buffer decision; **C12**, which the bridge will force anyway; **D16's `gas = 0`**,
a TER decision; and **D17**, which is not work.
**Fourteen of the seventeen findings are closed** (2026-08-03): all of section A, all of
B, D13D15, two thirds of D16, C10 and C11. What is left is **C12**, which the bridge will
force anyway; **D16's `gas = 0`**, a TER decision; and **D17**, which is not work.
`run` is `Result<RunOutcome, RunFailure>` over a typed `RunError`; host-fatal errors trap
instead of answering the guest a code; the import module is `host_lib`; the `i64` pipeline
and `AbiRet` are gone; the transfer budget counts only bytes actually copied host→guest,
and no more than the field cap can reach guest memory; the guest's linear memory is
resolved once, by kind rather than by name. See those entries for what landed and why.
resolved once, by kind rather than by name; a call that reads guest memory and writes it
borrows any number of inputs and answers through a per-run scratch, so a refused value
reaches guest memory in no part. See those entries for what landed and why.
**Three decisions were taken along the way**, each recorded at its finding:
**Four decisions were taken along the way**, each recorded at its finding:
**`OutOfTransferLimit` stays soft** (A1), **the import module name is `host_lib`** (A3),
and **the memory export is matched by kind, not by name** (section A's addendum). All
three restore C++ behaviour that the rewrite had changed without meaning to — which is
the pattern worth carrying into the bridge: on this path, "tidier than C++" is usually
**the memory export is matched by kind, not by name** (section A's addendum), and **the
scratch buffer owns the output, only where there is an input** (C11). All four restore or
extend C++ behaviour that the rewrite had changed without meaning to — which is the
pattern worth carrying into the bridge: on this path, "tidier than C++" is usually
"different from C++".
Every test that existed only to pin behaviour a finding said should change is gone,
@@ -941,14 +1024,12 @@ Consequences worth remembering:
- The ABI crate is now guest-linkable (`no_std`, no allocator, no runtime deps, checks
for `wasm32-unknown-unknown`) — see "The ABI crate is a library both sides link".
Next, from the findings above, **C11 and C12 are all that remain**, and neither is
ordinary work. **C11** is blocked on the scratch-buffer decision — see "Open: where the
output region points", and read its amendment first, because A4 spent that section's
strongest argument. **C12** (per-run `Linker`, no module cache) stays last:
`VmState<'h>`'s lifetime forces `Linker<VmState<'h>>` to be per-run, so it is a design
change rather than a tweak, and the cxx bridge will force that lifetime question anyway.
Of the seventeen findings, thirteen are closed; the other two open items are D16's
`gas = 0`, a TER decision, and D17, which is not work.
Next, from the findings above, **C12 is all that remains**, and it is not ordinary work:
per-run `Linker`, no module cache, and `VmState<'h>`'s lifetime forces
`Linker<VmState<'h>>` to be per-run, so it is a design change rather than a tweak — one
the cxx bridge will force the lifetime question on anyway. Of the seventeen findings,
fourteen are closed; the other two open items are D16's `gas = 0`, a TER decision, and
D17, which is not work.
The real remaining work is not in the findings list: **the cxx bridge**
(`xrpl-wasm-vm-ffi` is still `mod ffi {}`) and **real `ApplyContext` wiring**. A1 and A2