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_stdABI declaration:host_functions! { … }generates theHostFunctionstrait and theHostFunctionSpecenum (import name + gas per function). AlsoHostError. 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(onefunc_wrapper 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 sowatcannot 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-methodHostFunctionsinterface),HostFuncImpl*.cpp(its implementations, overApplyContext&),WasmCommon.h(HostFunctionError,Wmem,WasmTER,FieldLocator). The bridge's C++ half isHostContext.{h,cpp}(the ABI-shaped view ofHostFunctions) andWasmVM.{h,cpp}(runEscrowWasm, gas validation, the TER map).include/xrpl/tx/wasm/README.mdis stale: it uses the long nameget_ledger_sqnwhere the code registersldgr_index, and referencesdetail/WasmVM.cpp,detail/HostFuncWrapper.cpp,HostFuncWrapper.handParamsHelper.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
HostContextmethod isnoexceptand every body goes through oneguarded()that catchesstd::exceptionand..., journals, and returns -1. Nothing relies on cxx's owntrycatch, which only catchesstd::exceptionand only forResultreturns. - 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_unwindis 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 fine — xrpld 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
preflightEscrowWasm. The gap the TER map is currently papering over: a module that will not compile, instantiate, or expose the entry point maps totecINTERNALwith no cost, which is only defensible because preflight is meant to have refused it withtemBAD_WASMfirst. Nothing does that yet. It needs a second bridge entry that compiles and looks up the export without executing.- A caller.
EscrowFinish.cppstill has no wasm reference, sorunEscrowWasmis reached only fromsrc/tests/libxrpl/tx/WasmVM.cpp. Wiring it up is what makesWasmHostFunctionsImpl(over a realApplyContext) the host in production rather than in principle. - A gas parity oracle.
Wasm_test.cppasserts 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). - The
Bytes-by-value copy inHostFunctions— 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 moduleenv, nothost_lib(kLedgerSqnWasmHexdecodes to... 03 656e76 0a 6c6467725f696e646578 ...), and theirtarget_featuresincludesign-ext,multivalueandreference-types, which this engine disables. The deleted C++ engine ignored the import module name entirely —wasm_importtype_moduleis commented out at itsWasmiVM.cpp:428. So those fixtures are not usable as a parity oracle without recompiling them with-Wl,--import-module=host_liband the engine's feature set. The gtest carries its own WAT-derived fixtures for that reason. OutOfGasdoes 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 reports0: wasmi leaves the remaining fuel in place on its ownOutOfFueltrap, and onlyabi::chargeforces 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 invm::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.
- Lazy output buffer.
VmState::out_bufferis 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 isOption<[u8; N]>withget_or_insert_with(notOnceCell— that is for init behind a shared borrow;write_bufferedholds&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 theModule::newthat starts every run, and pays for it with a discriminant test on every host call — the direction C11 moved cost away from. Also noteOption<[u8; N]>does not shrinkVmState(no niche in a byte array), andOption<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. - 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
Moduleis 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 handingruna pre-compiled module, which changes the signature the bridge is about to consume. Either way the answer comes from the caller side. - Per-run
Linkeris blocked:VmState<'h>'s lifetime forcesLinker<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++HostContextfor one run and coerces to&'h dyn HostFunctionsunchanged, so hoisting the linker is still its own piece of work with no other reason to do it.
- 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
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.
Import module name— resolved:host_lib, matching the SDK and this fork's fixtures.the_import_module_name_must_matchrejectshost,envand the empty name.- Import name lineage. The fixtures pin the SDK at
branch = renamesand use short wire names (parent_ldgr_hash,cache_le,tx_inner_arr_len,accountroot_id,trustline_id), matchingldgr_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. - 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) removedOutOfGasfrom the guest's view, and the soft/fatal question is settled —OutOfTransferLimitstays soft. The encoding question is not:OutOfTransferLimit = -23still reaches a transmuting guest, andNoRuntime = -21would if anything returned it. Closing it needs either a range check in the SDK or a remap into-1..=-20. -1collides semantically — hostUnimplementedvs RustInternal. 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 reporttecINTERNAL, which is also what a C++ exception caught inHostContext::guardedbecomes. The guest-side half of the collision (its ownInternalError) is untouched.float_to_mant_expbyte count. The host returns 12 (8 mantissa + 4 exponent); the guest doc says 8, and the guest'smatch_result_code_with_expected_bytespanics on a non-negative mismatch. Note this function writes two output regions, a shape no current helper serves.- 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_cmp—0equal,1first >,2second >). - The SDK's drift checker is silently broken.
tools/compareHostFunctions.jsregex-parsesWasmVM.cppandHostFuncWrapper.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.
The ABI crate is a library both sides link
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 fillsVmState::out_bufferand the engine copies it to the guest once every rule has passed. Used by calls that also read guest memory, because a&mutview 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, nounsafe.
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 memory — Module::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-depsis part of the loop, not a nicety.lib.rscarriesdeny(rustdoc::broken_intra_doc_links), and neithercargo testnorclippychecks doc links. Caveat: it does not cover private modules, which are not documented by default — a dead link insideabi.rspasses silently (this is how aVmState::scratchlink survived theout_bufferrename). Add--document-private-itemsto check those, and grep after renaming a field.lib.rsalso carriesforbid(unsafe_code),deny(unreachable_pub)anddenyon 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
Callerexists only during a host call, so everything inabi.rsthat takes one cannot be reached from a unit test. Unit tests insrc/cover what needs no live instance (wire conversions, budget arithmetic, the limits); guest-memory policy lives intests/, running real modules against a configurable fake host. - Those integration tests write modules as WAT text and assemble it themselves —
watis a[dev-dependencies]entry andsupport::assembleits only caller, so the assembler never enters the library.runtakes binaries; there is norun_wat. tests/support/mod.rsholds the fake host and the import declarations.Answerseparates 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-vmis host-side and pulls in wasmi, andcrates/hello_worldcannot be checked for that target at all because it depends oncxx→link-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_stripdrops what no test path reaches. Verified: forcing a reference (let f: fn(&ffi::HostContext) -> _ = ...) fails withUndefined 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.cppandHostFuncImpl_test.cppare entirely inside/* */and compile to nothing, as issrc/libxrpl/tx/wasm/WasmiVM.cpp.- A stale build directory will fail to link with
duplicate symbol '_rust_eh_personality'. The conanwasmipackage ships a Ruststd, and so does our staticlib.b7059deb9fdropped the conan requirement but leftfind_package(wasmi REQUIRED)andwasmi::wasmiin the CMake, both now removed; a build folder generated before that still hasbuild/generators/wasmi-*.cmake, so re-runconan install .. --output-folder . --build missing --settings build_type=Debugand 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 plusset_dataregistered only increateWasmImport; e.g.ldgr_index60,sha512_half2000,set_data1000,float_pow5'500). - Guest-visible error codes:
HostFunctionErrorininclude/xrpl/tx/wasm/WasmCommon.h(-1Unimplemented… -20FloatComputationError; note -11 isOutOfTransferLimitthere,InvalidDecodingin 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 ininclude/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 unalignedFieldLocatorcopies (+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_bufferedfollows this;write_intocannot, since it must bounds-check before handing over a slice. - Entry point was
escrow_finish(escrowFunctionName); gas-1meant unlimited,<= 0meanttemBAD_AMOUNT; on out-of-gas the reported cost was the full limit. Positive return = conditions met;0or negative = reject. - wasmi's fuel table is consensus input — pin the version deliberately (currently
wasmi = "1.1.0").