Files
rippled/docs/claude/redesign_impl.md
2026-08-03 16:55:50 +01:00

45 KiB

rippled fork — Rust WASM VM work

What this branch is doing

Wasm-vm-redesign: replacing the C++ wasmi C-API integration with a Rust wasmi wrapper, written as production code built on the PoC's ideas — not a cleanup pass over the PoC.

Rust_wasm_PoC (and Rust_wasm_PoC_benchmark) are read-only reference branches. Their crates are named differently — host_functions, host_functions_macros, wasm_vm (with imports.rs where we have register.rs, plus ffi.rs), stdlib, example_contract — read with git show Rust_wasm_PoC:crates/<path>.

One PoC difference explains a lot of this crate. The PoC's host_abi! inserted &self, wrapped returns in HostResult<_>, and for a Vec<u8>/[u8; N] return appended out: &mut [u8] and changed the return to HostResult<usize>. Here the declaration is the signature — nothing is appended behind the reader's back. That is what "no magic" means throughout this document, and it is why byte outputs are written as explicit out-params.

The C-API path is gone: b7059deb9f ("Remove wasmi dependency") deleted WasmVM.{h,cpp}, WasmiVM.h, HostFuncWrapper.cpp and dropped the conan wasmi package. Old semantics are recoverable with git show b7059deb9f^:<path> — do that rather than guessing.

Where the code lives

  • crates/ — cargo workspace (edition 2024, resolver 3), built into the C++ build via corrosion (crates/CMakeLists.txt, which already registers the cxxbridge target).
    • xrpl-host-functions/no_std ABI declaration: host_functions! { … } generates the HostFunctions trait and the HostFunctionSpec enum (import name + gas per function). Also HostError. The single source of truth for the ABI.
    • xrpl-host-functions-macros/ — the proc macro. An implementation detail of the crate above, deliberately not re-exported: the ABI has one declaration site.
    • xrpl-wasm-vm/ — the wasmi wrapper. vm.rs (engine, store, run), abi.rs (gas, transfer budget, guest-memory marshaling), region.rs (the (ptr, len) type), register.rs (one func_wrap per host function).
    • xrpl-wasm-vm-ffi/ — the cxx bridge, both crossings. RunStatus/RunResult, run_escrow, CxxHost, the panic guard.
    • xrpl-wasm-testkit/test-only: compile_wat, so the C++ tests write their modules as WebAssembly text. A crate of its own so wat cannot reach the shipped node; see "How the C++ tests are built" below.
  • include/xrpl/tx/wasm/, src/libxrpl/tx/wasm/ — C++ side: HostFunc.h (the ~60-method HostFunctions interface), HostFuncImpl*.cpp (its implementations, over ApplyContext&), WasmCommon.h (HostFunctionError, Wmem, WasmTER, FieldLocator). The bridge's C++ half is HostContext.{h,cpp} (the ABI-shaped view of HostFunctions) and WasmVM.{h,cpp} (runEscrowWasm, gas validation, the TER map).
  • include/xrpl/tx/wasm/README.md is stale: it uses the long name get_ledger_sqn where the code registers ldgr_index, and references detail/WasmVM.cpp, detail/HostFuncWrapper.cpp, HostFuncWrapper.h and ParamsHelper.h, none of which exist.

Current state (2026-08-03)

The whole workspace is green: cargo test --workspace, clippy --workspace --all-targets, fmt, and cargo doc -p xrpl-wasm-vm --no-deps. 137 tests — 33 macro, 12 facade, 1 doctest, 79 in xrpl-wasm-vm (10 unit; 69 integration — 13 budgets, 12 host_calls, 23 memory_policy, 21 vm_limits), 10 in xrpl-wasm-vm-ffi, 2 in xrpl-wasm-testkit. On the C++ side, 27 tests over the whole loop in six fixtures: ./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'.

Both crossings are wired and a real contract runs through them: C++ calls runEscrowWasm, the engine services ldgr_index by calling back into xrpl::HostFunctions, and the guest reads the answer out of its own memory. Five host functions are registered (ldgr_index, home_le_field, sha512_half, trace, trace_num) out of the ~65 the full ABI will carry.

Seventeen of the eighteen review findings are closed, C12 the only one left (see the appendix). What is left overall: preflight and a caller (below), two performance items gated on a benchmark (below), and the open ABI questions.

The bridge, as built

crates/xrpl-wasm-vm-ffi/src/lib.rs is the whole of it. Two decisions carry the design.

The result is total, not Result<T>. cxx's Result sugar throws a rust::Error into C++; a status is the better interface for a condition the caller has to turn into a TER anyway. RunResult { status, result, gas_used, detail } flattens the engine's Result<RunOutcome, RunFailure> because a cxx enum carries no payload, and RunStatus is 1:1 with RunError plus Ok and Panic. Both directions of that map are compile-enforced: status_of's match is exhaustive over RunError, and C++'s switch over the generated enum has no default, so an outcome added to the engine fails to build until it has been given a status and a TER.

Neither side may unwind into the other, and the two halves are not symmetric.

  • A C++ exception is stopped in C++. Every HostContext method is noexcept and every body goes through one guarded() that catches std::exception and ..., journals, and returns -1. Nothing relies on cxx's own trycatch, which only catches std::exception and only for Result returns.
  • A Rust panic is caught in Rust, by guarded() in the bridge. [profile.release] turns overflow checks on, so this is a live path; #[cfg(panic = "abort")] compile_error! keeps a profile change from silently defeating it.
  • The asymmetry is what makes each half sufficient: because the C++ shims never unwind, every frame between a panic and catch_unwind is Rust.

HostContext holds a HostFunctions& and lowers its typed std::expected onto the wire. The &self-vs-non-const worry was a non-issue: a const member function holding a non-const reference can still call cacheLedgerObj/updateData. cxx_name on each method keeps ABI names on the Rust side and rippled's camelBack on the C++ side.

The five current functions needed no change to HostFunctions — the shim absorbs the uint32 → bytes (via adjustWasmEndianess, which is where the wasm boundary's byte order is decided for the whole system), i32 → SField and Hash → 32 bytes lowerings. Where that will stop being true: float_to_mant_exp (two output regions), the FieldLocator entries, and updateData.

The one copy left on the byte path, and why it needs HostFunctions to change

The engine's side of the byte path is copy-free by construction — write_into hands the host guest memory directly, write_buffered copies once after every rule has passed. The C++ side then puts a copy back, because HostFunctions returns its answer by value:

std::expected<Bytes, HostFunctionError> getCurrentLedgerObjField(SField const&) const;

Bytes is a std::vector<std::uint8_t>, so serving one field allocates, fills, gets copied into out by HostContext::answer, and is freed — a heap round trip per host call, on a consensus path, for a value the caller already has a buffer for. 49 of the 66 virtuals return Bytes this way; only the two Hash ones are inline.

The fix is an out-param form on HostFunctions itself — (…, std::span<std::uint8_t> out) -> std::expected<std::size_t, HostFunctionError>, returning the value's true length on the same "write only if it all fits" contract the ABI already uses end to end. That makes the convention identical on both sides of the bridge and leaves HostContext with no copy to make. Note the two shapes are not equivalent for every function: one that cannot know its length without building the value still allocates internally, so the win is real for field and keylet reads and smaller for the float ops.

Deliberately not done with the bridge: it touches 49 signatures plus WasmHostFunctionsImpl, HostFuncImpl*.cpp and the test hosts, which is a mechanical sweep that would bury the bridge in review. Sequence it after a caller exists, so the sweep can be measured against something that runs.

How the C++ tests are built

src/tests/libxrpl/tx/wasm/, in the xrpl_tests gtest binary. Four decisions, each of which had an obvious cheaper alternative that was worse.

Modules are WebAssembly text, assembled at run time. Checked-in hex blobs do not scale past one module — every host function needs its own, with its own import signature — and they are unreviewable. So compile_wat comes over cxx from crates/xrpl-wasm-testkit, a crate of its own that nothing in libxrpl or xrpld links.

That separation is the whole point and is worth not undoing. The engine pins wasmi = { default-features = false } because wasmi's wat feature makes Module::new accept text as readily as binary, which would make a transaction's validity a build flag (finding A5). Putting compile_wat on the production bridge would link an assembler into the shipped node even though nothing called it; a cargo feature would make the test and production binaries differ. A separate crate makes "no assembler in the node" a property of the link graph. WasmVMTest.ATextFormatModuleIsNotAModule then feeds the engine the very text the rest of the suite assembles, so the guest-side half of A5 is pinned too.

Two Rust staticlibs in one binary is finexrpld already links rs_hello_world and xrpl_wasm_vm_ffi. The _rust_eh_personality collision earlier in this document came from conan's separately compiled std, not from two crates in this workspace.

The host is a StrictMock. MockHostFunctions mocks only the methods the ABI declares; the ~60 others keep HostFunctions' Unimplemented default, so a contract reaching past the ABI fails the way production would. What this buys over a hand-written fake is assertions on what the host was asked — that a guest i32 became the right SField, that two borrowed regions and a flag all arrived, that an i64 survived as INT64_MIN.

Strict rather than nice, because these modules import exactly what they mean to exercise: a host call no test asked for means the engine reached for something on its own, which is worth a failure rather than a warning. The cost is one line in the fixture — EXPECT_CALL(host_, checkSelf()).WillRepeatedly(Return(true)), since runEscrowWasm asks every run whether the host is clean. Verified by mutation: giving escrow_finish an unstubbed host call fails the test under Strict and passes silently under NiceMock.

One trap worth knowing even so: gmock's default action for std::expected<T, E> is a successful T{}, so a method with an EXPECT_CALL but no action would answer 0 and a test could pass on an answer nobody chose. The mock's constructor therefore ON_CALLs every method to the base class's std::unexpected(Unimplemented).

Two levels of fixture. WasmTest holds the mock, a capturing journal sink and run(wat, gas, entryPoint). HostCallTest adds a wat() the derived fixture supplies and hostAnswer(), so a per-function test says only what the host was asked and what came back. Then one fixture per host function — LedgerSqnCall, CurrentLedgerObjFieldCall, Sha512HalfCall, TraceCall, TraceNumCall — because the module is that function's shared setup. WasmVMTest keeps what belongs to the engine rather than to any function.

The journal is captured, not sent to a null sink. AThrowingHostFunctionIsCaughtAndBecomes- Internal asserts the exception text and that the log names getLedgerSqn; without that, an exception silently swallowed with no log would pass, and HostContext::guarded's source_location would be untested.

Two properties are pinned from the guest's side rather than asserted about internals: ABufferTooSmallIsRefusedWholeRatherThanTruncated has the contract report whether anything reached its memory, which is "a refused value reaches it in no part" as a contract can observe it; and EverySoftHostErrorCodeCrossesToTheContractUnchanged walks all 18 soft HostFunctionError codes, because the C++ and Rust error enums are two hand-maintained lists of the same numbers that have already drifted once (-11 is OutOfTransferLimit in C++ and Decoding in the Rust ABI — open question 3).

Mutation-checked: making a too-large value write a truncated prefix, and pointing the sha512 input matcher at bytes the guest does not send, each fail exactly one test and nothing else.

Next: preflight, then a caller

  1. preflightEscrowWasm. The gap the TER map is currently papering over: a module that will not compile, instantiate, or expose the entry point maps to tecINTERNAL with no cost, which is only defensible because preflight is meant to have refused it with temBAD_WASM first. Nothing does that yet. It needs a second bridge entry that compiles and looks up the export without executing.
  2. A caller. EscrowFinish.cpp still has no wasm reference, so runEscrowWasm is reached only from src/tests/libxrpl/tx/WasmVM.cpp. Wiring it up is what makes WasmHostFunctionsImpl (over a real ApplyContext) the host in production rather than in principle.
  3. A gas parity oracle. Wasm_test.cpp asserts exact gas numbers (e.g. 29'502) and is the best oracle we have, but it is commented out and its fixtures cannot run on this engine (see below).
  4. The Bytes-by-value copy in HostFunctions — see "The one copy left on the byte path" below. A 49-signature sweep, so it wants a caller to measure against first.

Two findings from wiring the bridge, both worth knowing before the parity work:

  • The C fixtures under src/test/app/wasm_fixtures/ import from module env, not host_lib (kLedgerSqnWasmHex decodes to ... 03 656e76 0a 6c6467725f696e646578 ...), and their target_features include sign-ext, multivalue and reference-types, which this engine disables. The deleted C++ engine ignored the import module name entirely — wasm_importtype_module is commented out at its WasmiVM.cpp:428. So those fixtures are not usable as a parity oracle without recompiling them with -Wl,--import-module=host_lib and the engine's feature set. The gtest carries its own WAT-derived fixtures for that reason.
  • OutOfGas does not always report the whole limit. A contract that loops until the meter empties reports the full budget, but a budget too small to reach the first charge reports 0: wasmi leaves the remaining fuel in place on its own OutOfFuel trap, and only abi::charge forces it to zero. The deleted C++ path did this deliberately (iw.setGas(0) on out-of-gas, so the cost was always the full limit). Closing the gap is a one-line change in vm::run, but it is consensus-visible metadata, so it is called out rather than slipped in.

Performance, gated on a benchmark

Two optimisations are deferred pending measurement, not rejected. Neither is worth guessing at, and one benchmarking pass with the google-benchmark harness on a host-call-heavy module settles both.

  1. Lazy output buffer. VmState::out_buffer is an inline [u8; MAX_FIELD_BYTES], zero-filled once per run whether or not the contract makes a call that uses it. The lazy form is Option<[u8; N]> with get_or_insert_with (not OnceCell — that is for init behind a shared borrow; write_buffered holds &mut VmState). The case against it today is a magnitude argument 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 — the direction C11 moved cost away from. Also note Option<[u8; N]> does not shrink VmState (no niche in a byte array), 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.
  2. C12: cached Linker, cached module. Two independent halves.
    • Module compile cache is the bigger win — a whole wasm translation per run versus building a five-entry linker — and it is not blocked by the lifetime problem, since a Module is engine-scoped. But it carries a question that is not the engine's to answer: who owns a compiled contract's lifetime? An unbounded static cache inside a library on a consensus path brings an eviction policy nobody asked for; the alternative is handing run a pre-compiled module, which changes the signature the bridge is about to consume. Either way the answer comes from the caller side.
    • Per-run Linker is blocked: VmState<'h>'s lifetime forces Linker<VmState<'h>> to be per-run, so hoisting it means making the store data 'static — not holding &'h dyn HostFunctions. This was expected to fall out of the bridge; it did not. CxxHost<'a> borrows the C++ HostContext for one run and coerces to &'h dyn HostFunctions unchanged, so hoisting the linker is still its own piece of work with no other reason to do it.

So: measure first, and treat the linker and the lazy buffer as whatever the numbers say. The module cache's open question is unchanged by the bridge — run(wasm, gas, host, name) is now a signature the C++ side consumes, so handing it a pre-compiled module is a change to a live interface rather than a hypothetical one, and who owns a compiled contract's lifetime is still the caller's question to answer.

Open decisions

gas = 0 — resolved: refused in C++ as temBAD_AMOUNT. The old code already decided this and the doc had it garbled: WasmiEngine::run rejected gas <= 0 for every value including -1, and -1 = unlimited applied only to preflight's check. runEscrowWasm restores exactly that, which is why the engine's own budget stays a u64 with no invalid value to represent.

ABI / guest-SDK interop. Found auditing the guest SDK (~/Documents/rust/xrpl-wasm-stdlib, checkout 435a091f) against this fork. All are decisions rather than code.

  1. Import module nameresolved: host_lib, matching the SDK and this fork's fixtures. the_import_module_name_must_match rejects host, env and the empty name.
  2. Import name lineage. The fixtures pin the SDK at branch = renames and use short wire names (parent_ldgr_hash, cache_le, tx_inner_arr_len, accountroot_id, trustline_id), matching ldgr_index / home_le_field / sha512_half. The standalone SDK checkout is the long-name lineage (get_parent_ledger_hash, cache_ledger_obj, compute_sha512_half). Which is authoritative is undecided.
  3. New error codes are UB in the guest. The SDK decodes with a bare transmute and no range check (xrpl-common-stdlib/src/host/mod.rs:325), valid only for -1..=-20. Making host-fatal errors trap (A1) removed OutOfGas from the guest's view, and the soft/fatal question is settled — OutOfTransferLimit stays soft. The encoding question is not: OutOfTransferLimit = -23 still reaches a transmuting guest, and NoRuntime = -21 would if anything returned it. Closing it needs either a range check in the SDK or a remap into -1..=-20.
  4. -1 collides semantically — host Unimplemented vs Rust Internal. Now a decision rather than an accident: the bridge treats them as one condition, "the host could not serve this call, and the contract has no business interpreting why". Both are host-fatal, so both stop the run and report tecINTERNAL, which is also what a C++ exception caught in HostContext::guarded becomes. The guest-side half of the collision (its own InternalError) is untouched.
  5. float_to_mant_exp byte count. The host returns 12 (8 mantissa + 4 exponent); the guest doc says 8, and the guest's match_result_code_with_expected_bytes panics on a non-negative mismatch. Note this function writes two output regions, a shape no current helper serves.
  6. Return conventions are not uniform — six of them: bytes-written; value-in-return (*_arr_len, nft_flags); boolean 0/1 (amendment_enabled, check_sig); 1-based handle (cache_le, always ≥ 1); status-0 (trace*, set_data); tri-state (float_cmp0 equal, 1 first >, 2 second >).
  7. The SDK's drift checker is silently broken. tools/compareHostFunctions.js regex-parses WasmVM.cpp and HostFuncWrapper.h, both deleted. A generated C header would give it a stable target again.

The ABI: one declaration, three outputs

crates/xrpl-host-functions/ is the one declaration. C compatibility adds a third output beside the trait and the spec enum — a generated, checked-in C header with a CI diff — not a second input. "Explicit vs hidden" is the wrong axis; "derivable and emitted" is the right one, because C authors read a header, not a macro.

The lowering table

The DSL already implied this; it was never written down, and that was the whole gap.

params, in declared order:
  &self              -> nothing                   (receiver, not part of the ABI)
  i32, bool          -> i32                       (bool: nonzero = true)
  i64                -> i64
  &[u8], &str        -> i32 ptr, i32 len          const uint8_t*, int32_t
  &mut [u8]          -> i32 ptr, i32 len          uint8_t*, int32_t   (an output region)

returns, always HostResult<T>; Err(e) -> negative code, or a trap when host-fatal:
  HostResult<usize>       -> i32 = bytes written into the output region
  HostResult<i32>, <bool> -> i32 = the value
  HostResult<()>          -> i32 = 0

Total, unambiguous and positional: every wasm parameter is a declared parameter in order, so the C prototype is a direct reading of the declaration. The macro must reject any type not in this table — that is C++'s WasmImpArgs static_assert restored, and it is what keeps the C API always surfaceable.

All five current declarations lower to exactly the deleted C++ _proto aliases (verified 2026-07-29; &self dropped below since it contributes no C parameter):

Declaration Derived C
get_ledger_sqn(out: &mut [u8]) -> HostResult<usize> int32_t(uint8_t*, int32_t)
get_current_ledger_obj_field(field: i32, out: &mut [u8]) -> HostResult<usize> int32_t(int32_t, uint8_t*, int32_t)
sha512_half(data: &[u8], out: &mut [u8]) -> HostResult<usize> int32_t(const uint8_t*, int32_t, uint8_t*, int32_t)
trace(msg: &str, data: &[u8], as_hex: bool) -> HostResult<()> int32_t(const uint8_t*, int32_t, const uint8_t*, int32_t, int32_t)
trace_num(msg: &str, number: i64) -> HostResult<()> int32_t(const uint8_t*, int32_t, int64_t)

Discipline the table requires: a byte output is an explicit out: &mut [u8] plus HostResult<usize>, never a returned value. get_ledger_sqn writes 4 LE bytes and returns 4 — it does not return the sequence number; by the same rule float_to_int takes an out region rather than returning i64. A scalar HostResult<T> means value-in-the-return.

The out-region contract, which the engine relies on: write only if the whole value fits, and return its true length either way. So a host never needs to know the guest's buffer size — the engine turns n > cap into BufferTooSmall.

Closing the drift gap to register.rs

wasmi 1.1 cannot introspect a registered host function's signature. Linker::get returns None for func_wrap'd functions — they land in Definition::HostFunc (wasmi-1.1.0/src/linker.rs:147), and Definition::ty() exists at :171 but Definition and get_definition are private. So "assert Func::ty() equals the spec" is unavailable.

Approach register.rs Guarantee
Generate closures wholesale disappears by construction
Generate link_* shims, hand-write bodies stays, readable compile-time
Hand-write everything + probe-module test stays test-time

Preferred: the middle row — the macro emits the type without the body:

pub type Sha512HalfFn =
    fn(Caller<'_, VmState<'_>>, i32, i32, i32, i32) -> Result<i32, wasmi::Error>;

pub fn link_sha512_half(l: &mut Linker<VmState<'_>>, f: Sha512HalfFn)
    -> Result<(), LinkerError> { l.func_wrap(MODULE, HostFunctionSpec::Sha512Half.wasm_name(), f) }

Wrong arity, scalar type or return then becomes a compile error, and the same lowering table emits both the alias and the C prototype so they cannot drift. Constraint: fn pointers accept only non-capturing closures; every arm today is non-capturing, and one that needs to capture can take impl Fn(..) + Send + Sync + 'static instead. Cheap extra worth having: a probe-module test that synthesises a WAT module importing every function with its declared type and instantiates it — the only check that also catches module-name and missing-import mistakes, from the guest's side.

Deferred together: the shims, the generated header, the probe test.

It is consumed as an ordinary dependency — by xrpl-wasm-vm today, the guest stdlib next. Neither invokes host_functions!; consumers get the generated code, not the generator. That makes four properties load-bearing:

Property Why Status
#![no_std], no allocator the guest stdlib is strictly no_std Vec left when byte outputs became out: &mut [u8]
zero runtime dependencies anything else must also build for the guest cargo tree is the proc-macro crate alone
builds for wasm32-unknown-unknown it links into the guest ✓ verified
implementable by both sides one declaration, two implementors ✓ the out-param shape is what buys this

A host impl writes into out and returns the length; a guest impl forwards to the import and decodes the i32 through HostError::from_code, which range-checks (unlike the SDK's transmute — question 3). One trait serves both because the declaration is now the wire shape.

Known gap. The #[link(wasm_import_module = "…")] unsafe extern "C" { … } block is not generated; the PoC's macro did generate it plus a GuestHost impl. If the stdlib hand-writes it, that is precisely the drift a single source of truth exists to prevent. One wrinkle to decide first: a generated guest impl needs HostError::from_code, a name no declaration mentions, so it would be the first vocabulary dependency inside an otherwise closed expansion.

Convention: the expansion is closed. Every name in it is generated or written in the declarations; names_no_crate_of_its_own enforces it. The macro owns HostFunctions, HostFunctionSpec, ALL, wasm_name(), gas() and the private HostFnSpec row type. The facade hand-writes only the vocabulary declarations are written in — HostError, HostResult, HASH_LEN — which resolve at the call site like &[u8] does. HostFnSpec and spec() are private; read the table through wasm_name() / gas().

How the engine works

Contracts worth knowing before changing anything, and the reasons that are not visible in the code.

Two channels for a result. A value or a guest-actionable error is the i32 the wasm function returns (>= 0 value, < 0 a HostError code). A host-fatal error — OutOfGas, Internal, NoMemExported — traps instead, carrying FatalHostError(HostError) as the payload so run can name the condition with downcast_ref rather than string-comparing a message. XLS-0102 requires immediate halting on gas exhaustion, and a guest handed OutOfGas as a code would run to the end of its current basic block — a stopping point wasmi's ConsumeFuel placement decides rather than the protocol. is_fatal spells the set variant by variant so a new HostError's channel is chosen, not inherited from its number. OutOfTransferLimit is soft: the one budget a contract can be expected to handle.

is_fatal and vm::host_fatal are two lists that must agree. One direction is compiler-enforced (host_fatal is exhaustive, so a new variant fails to build); every_fatal_error_has_an_outcome_of_its_own covers the other. HostError::ALL and HostFunctionSpec::ALL exist because an exhaustive match forces you to write an arm but cannot enumerate variants, and every const-assertion scheme over ALL is beaten by "add the variant, give its arm a value, leave ALL alone". The airtight mechanism is a single declaration site: a host_errors! macro emits the enum, ALL and from_code from one list.

Every failure carries its cost. run returns Result<RunOutcome, RunFailure> where RunFailure is { error: RunError, fuel_used }, so gas is on both paths by construction. A cost that cannot be read becomes RunError::Internal rather than a number — 0 would forgive a run its whole cost and gas would charge an untouched one for everything. guest_halted asks "did the guest halt?" at every stage from instantiation on, so a start section that burns the limit is OutOfGas, not Instantiate: the stage a run stopped at is not what the caller maps.

Two ways a byte answer reaches the guest, both taking a Region:

  • write_into — the host writes straight into the guest's output region. Used by calls with no byte input. Zero copy.
  • write_buffered — the host fills VmState::out_buffer and the engine copies it to the guest once every rule has passed. Used by calls that also read guest memory, because a &mut view of that memory admits no simultaneous & view. Memory::data_and_store_mut (memory/mod.rs:165) returns (&mut [u8], &mut T) — guest bytes and store data in one split borrow — which is what lets any number of inputs stay borrowed while the answer is written. No copying inputs out, no unsafe.

write_buffered never tells the host the guest's capacity: it offers the whole buffer and takes the value's true length, so nothing reaches guest memory until the length, bounds, fit and budget have all passed — a refused value reaches it in no part, which write_into can only bound rather than prevent. The output is judged after the inputs, so a call with both malformed reports the input's verdict; NoMemExported precedes both, because there is no memory to validate a region against. MAX_FIELD_BYTES is checked beside the clamp on purpose: the clamp bounds the bytes, the check bounds the status, since a host reports a true length that can exceed the region it was offered.

Why this shape: of the ~65 ABI entries, 38 have a byte input and a byte output, 9 are output-only, 18 are input-only or scalar. Of the 38, 22 have more than one region — every two-argument keylet, nft_uri, all four float arithmetic ops — which a one-input helper cannot express at all. The 9 output-only ones are exactly the row a buffer makes worse, and they keep write_into.

Region (region.rs) is the wire's (ptr, len) as one type. It cannot catch a swapped pair — Region::new(len, ptr) compiles, and no type can do better where the values arrive as indistinguishable i32s in positional order; that is a job for a reader or for the shim generator. What it enforces is that the pair cannot be used unchecked: range() is the only way to indices, and it is where InvalidParams (the conversion to usize is the negativity check) and the end-overflow guard live. It sits in its own module because Rust privacy is module-level — inside abi.rs the helpers could still read .ptr and skip the check. Verified: an attempted bypass is error[E0616]: field ptr of struct Region is private. Construction is infallible on purpose; validating in new would hoist the output region's verdict above the host call and break the input-first order that a_read_write_checks_its_input_before_its_output pins.

Region::read is then ordinary safe slicing, because a guest pointer is an index: wasm linear memory is a byte array in the store, mem.data(caller) is a &[u8] over it, and data.get(start..end) does the bounds check and returns a slice aliasing guest memory. get rather than [..] because indexing panics, and a panic on a consensus path is a node crash. Elision ties the returned slice's lifetime to data, so a host cannot stash an input past the call.

Two budgets. Gas is charged per host call from the spec table, before the body runs (charged is the one path, so it cannot be forgotten); exhaustion spends what is left, which is what makes the reported cost the whole limit. The transfer budget counts only bytes copied across — charge_transfer has one call site per write path. A borrowed read copies nothing and is not charged; what bounds how many reads a run makes is gas. Typed reads that materialise a host object will charge; this ABI has none yet, and the alignment-copy charge for unaligned field reads has nothing to attach to until a FieldLocator function exists.

Guest memory is resolved once per run, by kind. run takes instance.exports(&store).find_map(Export::into_memory) after instantiate_and_start and keeps the handle in VmState::memory, so no call pays for an export lookup. By kind, never by name: nothing in the wasm spec attaches meaning to "memory". Caching is sound because a Memory is an arena index, not a pointer — it survives memory.grow. Two consequences: the field assumes one module, one instance, one store per run (module linking would have to resolve per instance, or serve a call against the wrong memory), and a start section cannot make a host call needing memoryModule::instantiate is pub(crate), so instantiation cannot be split from the start section. a_start_section_cannot_make_a_host_call pins it.

Engine config is consensus-fixed: fuel on, floats off, every post-MVP proposal off, one process-wide Engine behind a LazyLock (an Engine is internally Arced and Send + Sync). Notably wasmi = { default-features = false, features = ["std"] } — wasmi's wat feature is on by default and makes Module::new accept text as readily as binary, which would put a text assembler in the consensus path and make a transaction's validity a build flag. the_vm_refuses_a_text_format_module catches that coming back.

A start section cannot be rejected outright. wasmi 1.1 exposes no InstancePre/ensure_no_start and ModuleHeader::start is private, so only a byte-level section scan would do it. It is metered and memory-capped regardless, since run installs the fuel and the limiter before instantiate_and_start.

A dead end, recorded so nobody retries it. Host-function parameters cannot be newtypes. wasmi::WasmTy looks implementable — public, no sealing supertrait — but its bound names UntypedVal, which wasmi re-exports only through a private mod core (wasmi-1.1.0/src/lib.rs:109-137). Probed: error[E0603]: module core is private. The escape hatch is a direct wasmi_core dependency pinned in lockstep with wasmi's own, plus a #[doc(hidden)] method — not worth it on a consensus path. So the wire stays i32 and pairs are formed on the first line of each arm.

Build / test loop

  • Fast: cd crates && cargo check --workspace --all-targets, cargo test --workspace, cargo clippy --workspace --all-targets.
  • cargo doc -p xrpl-wasm-vm --no-deps is part of the loop, not a nicety. lib.rs carries deny(rustdoc::broken_intra_doc_links), and neither cargo test nor clippy checks doc links. Caveat: it does not cover private modules, which are not documented by default — a dead link inside abi.rs passes silently (this is how a VmState::scratch link survived the out_buffer rename). Add --document-private-items to check those, and grep after renaming a field. lib.rs also carries forbid(unsafe_code), deny(unreachable_pub) and deny on four clippy cast lints, so an unargued cast fails the build rather than warning.
  • Tests come in two kinds, and the split is forced. A wasmi Caller exists only during a host call, so everything in abi.rs that takes one cannot be reached from a unit test. Unit tests in src/ cover what needs no live instance (wire conversions, budget arithmetic, the limits); guest-memory policy lives in tests/, running real modules against a configurable fake host.
  • Those integration tests write modules as WAT text and assemble it themselves — wat is a [dev-dependencies] entry and support::assemble its only caller, so the assembler never enters the library. run takes binaries; there is no run_wat.
  • tests/support/mod.rs holds the fake host and the import declarations. Answer separates what the host writes from what length it reports, which is what makes the over-cap and buffer-fit rules testable without values that large existing.
  • Guest-linkability (needs rustup target add wasm32-unknown-unknown): cargo check -p xrpl-host-functions --target wasm32-unknown-unknown. Only the ABI crate — xrpl-wasm-vm is host-side and pulls in wasmi, and crates/hello_world cannot be checked for that target at all because it depends on cxxlink-cplusplus, which wants a C++ toolchain for the target.
  • The bridge crate's unit tests link only because nothing in them reaches a C++ shim. The extern "C++" symbols exist only in the CMake build, and the test binary links because -dead_strip drops what no test path reaches. Verified: forcing a reference (let f: fn(&ffi::HostContext) -> _ = ...) fails with Undefined symbols: _rs$wasm_vm$cxxbridge1$…. So keep those tests on the pure logic — the status map, the panic guard, the wire conversions — and put anything that needs a host in the gtest.
  • Full C++↔Rust: normal CMake build, then ./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'. See "How the C++ tests are built".
  • src/test/app/Wasm_test.cpp and HostFuncImpl_test.cpp are entirely inside /* */ and compile to nothing, as is src/libxrpl/tx/wasm/WasmiVM.cpp.
  • A stale build directory will fail to link with duplicate symbol '_rust_eh_personality'. The conan wasmi package ships a Rust std, and so does our staticlib. b7059deb9f dropped the conan requirement but left find_package(wasmi REQUIRED) and wasmi::wasmi in the CMake, both now removed; a build folder generated before that still has build/generators/wasmi-*.cmake, so re-run conan install .. --output-folder . --build missing --settings build_type=Debug and delete them.
  • VCS is jj (jj st, jj log), not raw git, for local work.

Conventions

Comments. Terse. A comment should say something the compiler cannot check and the code cannot show; everything else is a candidate for deletion. Keep: why an apparent redundancy is not one (the MAX_FIELD_BYTES check beside the clamp; is_fatal/host_fatal as two lists; MUST_TRAP not deriving from is_fatal — each of these has been "simplified" wrongly in a mutation test at least once); load-bearing invariants; hidden contracts a signature cannot state; wasmi facts that decide a design. Cut: prose restating the next line; the same rationale on a field and on its reader; retellings of this document.

No references to C++ that will not survive the merge. They read as evidence but point at deleted files. The crate has none, in src/ or tests/. Two live exceptions stand: Protocol.h's kMaxWasmDataLength and kWasmTransferLimit, which are where those numbers are defined for the rest of the system. The parity evidence itself lives here instead — see the appendix, which is commit-pinned and therefore stays resolvable.

No historical comments in code — describe the present, not how it differs from a previous state.

Appendix: review findings (2026-07-29)

A read of vm.rs, abi.rs and register.rs against the vendored wasmi 1.1.0. Seventeen of the eighteen are closed, C12 the only one left — earlier revisions of this document said "fourteen of seventeen", which never matched the table. The rationale that is still load-bearing has moved into "How the engine works" above.

# Finding Outcome
A1 Out-of-gas returned a code instead of trapping, so how much guest code ran after exhaustion was wasmi's business ✓ two-channel design, FatalHostError payload
A2 run discarded gas accounting on every failure path, and its error was a String RunFailure { error, fuel_used } over a typed RunError
A3 HOST_MODULE = "host" matched no guest that exists host_lib, pinned by test
A4 Transfer budget charged for bytes never copied, and charged before validation ✓ one call site per write path; reads are free
A5 Module::new accepted WAT text — a behaviour the rewrite introduced by accident default-features = false
A6 The memory export's name was a rule the rewrite introduced ✓ resolved by kind, as C++ did
B6 AbiRet was vestigial ✓ deleted
B7 The i64 pipeline was pointless and lossy (silent truncating cast) HostResult<i32> end to end
B8 cxx was an unused dependency of this crate ✓ removed
B9 Seven broken intra-doc links, plus historical comments ✓ fixed, deny added
C10 The "memory" export was a string hash lookup on every host call ✓ resolved once per run, by kind
C11 read_write memset 1 KiB of stack per call and did not generalize past one byte input ✓ replaced by write_buffered + Region
C12 Linker rebuilt per run; module compiled per run with no cache open — see "Performance"
D13 The public surface was accidental (RunOutcome unnameable, limits unreachable) ✓ exported; MAX_FIELD_BYTES renamed out of abi.rs
D14 abi.rs claimed every access was a checked slice op forbid(unsafe_code) + cast lints enforce it
D15 Zero tests ✓ 79
D16 gas = 0 accepted silently; get_fuel().unwrap_or(0) reported the whole limit; entry-point diagnostic wrong for a wrong-signature export ✓ closed — gas <= 0 is temBAD_AMOUNT in runEscrowWasm
D17 The start-section TODO reads like a hole ✓ documented as not closeable with wasmi 1.1

Three of the closed findings were behaviour changes nobody had chosen — A3, A5, A6 — and all three restored C++ behaviour the rewrite had altered by accident. That is the pattern worth carrying into the bridge: on this path, "tidier than C++" is usually "different from C++". A fourth decision, C11's buffer, extends it rather than restoring it.

Appendix: reference points from the deleted C++ path

Import names and gas costs are ABI. The rest is evidence of prior behaviour — useful for comparison and for the gas assertions in Wasm_test.cpp, not gospel. All recoverable with git show b7059deb9f^:<path> — note that WasmVM.{h,cpp} exist again at that path with entirely different contents, so the revision in that command is doing real work.

Deleted later, with the dead wasmi::wasmi link that was the only thing supplying their <wasm.h>: include/xrpl/tx/wasm/HostFuncWrapper.h (the *_proto aliases and *_wrap declarations, whose .cpp went in b7059deb9f) and WasmImportsHelper.h (ImportVec, WasmImpArgs's static_assert). Every remaining reference to either was inside a commented-out file. The _proto aliases are the C lowering the table above reproduces, so they are worth reading before extending it.

  • Import names + per-call gas: src/libxrpl/tx/wasm/WasmVM.cpp (setCommonHostFunctions, 64 entries plus set_data registered only in createWasmImport; e.g. ldgr_index 60, sha512_half 2000, set_data 1000, float_pow 5'500).
  • Guest-visible error codes: HostFunctionError in include/xrpl/tx/wasm/WasmCommon.h (-1 Unimplemented … -20 FloatComputationError; note -11 is OutOfTransferLimit there, InvalidDecoding in the SDK — question 3).
  • Host-fatal conditions were traps: out-of-gas and internal errors threw hfErrOutOfGas / hfErrInternal → trap → tecOUT_OF_GAS / tecINTERNAL. Only the transfer limit was a soft, guest-visible failure.
  • Limits: maxPages = 128 (8 MiB), kMaxWasmDataLength = 1024, kWasmTransferLimit = 1 << 20. The last two are still live in include/xrpl/protocol/Protocol.h:328,333.
  • Transfer limit was charged for bytes actually copied: host→guest writes (setData) and typed reads materialising a host object (uint256, AccountID, Currency, Asset), plus unaligned FieldLocator copies (+unalignedGas = 50). Plain slice/string reads were not charged.
  • Check order after a value existed (setData): params → data-too-large → no-memory → out-of-bounds → buffer-too-small → transfer → copy. Inputs (getDataSlice) were validated before the call. write_buffered follows this; write_into cannot, since it must bounds-check before handing over a slice.
  • Entry point was escrow_finish (escrowFunctionName); gas -1 meant unlimited, <= 0 meant temBAD_AMOUNT; on out-of-gas the reported cost was the full limit. Positive return = conditions met; 0 or negative = reject.
  • wasmi's fuel table is consensus input — pin the version deliberately (currently wasmi = "1.1.0").