This commit is contained in:
Sergey Kuznetsov
2026-07-28 17:02:56 +01:00
parent 641ecb47bd
commit d8d1ec46dc
8 changed files with 1042 additions and 16 deletions

163
crates/Cargo.lock generated
View File

@@ -8,6 +8,18 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.61"
@@ -66,7 +78,7 @@ dependencies = [
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
"foldhash",
"foldhash 0.2.0",
"link-cplusplus",
]
@@ -129,12 +141,27 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
@@ -148,9 +175,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.0",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "link-cplusplus"
version = "1.0.12"
@@ -160,6 +199,12 @@ dependencies = [
"cc",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -227,6 +272,22 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -276,6 +337,99 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "wasm-encoder"
version = "0.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393"
dependencies = [
"leb128fmt",
"wasmparser 0.254.0",
]
[[package]]
name = "wasmi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.239.0",
"wat",
]
[[package]]
name = "wasmi_collections"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.239.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0"
dependencies = [
"bitflags",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27"
dependencies = [
"bitflags",
"indexmap",
]
[[package]]
name = "wast"
version = "254.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c"
dependencies = [
"wast",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -319,6 +473,11 @@ dependencies = [
[[package]]
name = "xrpl-wasm-vm"
version = "0.1.0"
dependencies = [
"cxx",
"wasmi",
"xrpl-host-functions",
]
[[package]]
name = "xrpl-wasm-vm-ffi"

View File

@@ -34,7 +34,7 @@ impl ParsedHostFunction {
// The receiver is not part of the wasm ABI, so declarations omit it and
// only the trait needs one.
let mut signature = self.signature.clone();
signature.inputs.insert(0, parse_quote!(&mut self));
signature.inputs.insert(0, parse_quote!(&self));
quote! {
#(#docs)*

View File

@@ -4,3 +4,6 @@ version = "0.1.0"
edition.workspace = true
[dependencies]
wasmi = "1.1.0"
cxx.workspace = true
xrpl-host-functions = { path = "../xrpl-host-functions" }

View File

@@ -0,0 +1,247 @@
use crate::vm::VmState;
use wasmi::{Caller, Extern, Memory};
use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult};
// ---------------------------------------------------------------------------
// ABI marshaling traits: decode a host-function argument from wasm scalars +
// guest memory (`AbiArg`), encode a result back into guest memory and a wasm
// return status (`AbiRet`), and a single-point gas-charging wrapper
// (`charged`) so every registered closure pays for its call exactly once.
// ---------------------------------------------------------------------------
/// Encode a *scalar or unit* host-function result into the status the wasm fn
/// returns (>= 0 success — a value; < 0 a HostError code, via `to_wasm_*`).
/// `Out` is the extra wasm scalars for output — always `()` here, since these
/// returns need no guest buffer.
///
/// Value-producing returns (`Vec<u8>` / `[u8; N]`) do *not* go through this
/// trait: they are serviced by [`write_into`], where the host writes straight
/// into guest linear memory with no owned buffer to encode.
pub(crate) trait AbiRet {
type Out;
fn write(self, caller: &mut Caller<'_, VmState<'_>>, out: Self::Out) -> HostResult<i64>;
}
impl AbiRet for () {
type Out = ();
fn write(self, _c: &mut Caller<'_, VmState<'_>>, _o: ()) -> HostResult<i64> {
Ok(0)
}
}
impl AbiRet for u32 {
type Out = ();
fn write(self, _c: &mut Caller<'_, VmState<'_>>, _o: ()) -> HostResult<i64> {
Ok(self as i64)
}
}
/// Charge a host call's gas once (from the enum's spec) then run its body.
/// Because every registered closure goes through here, gas can't be forgotten.
pub(crate) fn charged(
caller: &mut Caller<'_, VmState<'_>>,
op: HostFunctionSpec,
body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> HostResult<i64>,
) -> HostResult<i64> {
charge(caller, op.spec().gas)?;
body(caller)
}
pub(crate) fn to_wasm_i32(r: HostResult<i64>) -> i32 {
match r {
Ok(v) => v as i32,
Err(e) => e.code(),
}
}
#[allow(dead_code)]
pub(crate) fn to_wasm_i64(r: HostResult<i64>) -> i64 {
match r {
Ok(v) => v,
Err(e) => e.code() as i64,
}
}
// ---------------------------------------------------------------------------
// Gas + bounds-checked memory helpers (the crate's only "unsafe surface",
// concentrated and safe: every access is a checked wasmi slice op)
// ---------------------------------------------------------------------------
/// Per-field size cap for any single value crossing the host/guest boundary.
///
/// Mirrors `kMaxWasmDataLength = 1 * 1024` in
/// `include/xrpl/protocol/Protocol.h:261`, enforced by `getDataSlice`/
/// `setData` (`src/libxrpl/tx/wasm/HostFuncWrapper.cpp`) returning
/// `DataFieldTooLarge`.
const MAX_WASM_DATA_LEN: usize = 1024;
/// Deduct `cost` fuel for a host call; `OutOfGas` if it would go negative.
fn charge<T>(caller: &mut Caller<'_, T>, cost: u64) -> Result<(), HostError> {
let remaining = caller.get_fuel().map_err(|_| HostError::Internal)?;
match remaining.checked_sub(cost) {
Some(left) => caller.set_fuel(left).map_err(|_| HostError::Internal),
None => {
let _ = caller.set_fuel(0);
Err(HostError::OutOfGas)
}
}
}
/// Deduct `n` bytes from the per-run transfer-limit budget (see
/// [`crate::vm::TRANSFER_LIMIT_BYTES`]); `OutOfTransferLimit` if it would go
/// negative. A separate budget from gas — see `VmState::transfer_budget`.
fn charge_transfer(state: &VmState<'_>, n: usize) -> Result<(), HostError> {
let n = n as u64;
let remaining = state.transfer_budget.get();
match remaining.checked_sub(n) {
Some(left) => {
state.transfer_budget.set(left);
Ok(())
}
None => Err(HostError::OutOfTransferLimit),
}
}
/// The guest's exported linear memory.
fn memory<T>(caller: &Caller<'_, T>) -> Result<Memory, HostError> {
match caller.get_export("memory") {
Some(Extern::Memory(mem)) => Ok(mem),
_ => Err(HostError::NoMemExported),
}
}
/// Bounds-check `[ptr, ptr + len)` and return a `&[u8]` **aliasing guest linear
/// memory** — no allocation, no copy. The read analog of [`write_into`]: where
/// `write_into` hands the host a `&mut [u8]` into guest memory, this hands it a
/// `&[u8]`, so a *read-only* host call touches the guest's bytes in place.
///
/// The returned slice borrows `caller`, so it is valid only for the duration of
/// the host call it feeds — the same leaf-call invariant `write_into` relies on
/// (our host functions don't re-enter the guest and move its memory).
///
/// Checks, in order: params validity, the [`MAX_WASM_DATA_LEN`] size cap
/// (`DataFieldTooLarge`), then the transfer-limit budget — all before the
/// slice is formed.
pub(crate) fn read_borrowed<'a>(
caller: &'a Caller<'_, VmState<'_>>,
ptr: i32,
len: i32,
) -> HostResult<&'a [u8]> {
if ptr < 0 || len < 0 {
return Err(HostError::InvalidParams);
}
let (ptr, len) = (ptr as usize, len as usize);
if len > MAX_WASM_DATA_LEN {
return Err(HostError::DataFieldTooLarge);
}
charge_transfer(caller.data(), len)?;
let end = ptr.checked_add(len).ok_or(HostError::PointerOutOfBounds)?;
memory(caller)?
.data(caller)
.get(ptr..end)
.ok_or(HostError::PointerOutOfBounds)
}
/// Service a "fill-the-caller's-buffer" host call: bounds-check the guest
/// output region `[dst, dst + cap)`, hand the host a `&mut [u8]` aliasing it,
/// and let the host write **straight into guest linear memory** — the single
/// copy, with no owned buffer intermediate (this is what removes the extra copy
/// the value-producing host functions used to pay: a `Vec<u8>` / `[u8; N]`
/// materialized on the host side, then copied into guest memory. The `CxxHost`
/// path additionally used to marshal C++ `Bytes` through a `rust::Vec` /
/// `HashResult`; that too is gone).
///
/// `fill` returns the value's *true* length (it writes only when the value fits
/// in `dst`), so the engine keeps ownership of the policy the guest observes:
/// the [`MAX_WASM_DATA_LEN`] field-size cap (`DataFieldTooLarge`), the
/// buffer-fit check (`BufferTooSmall`), and the transfer-limit budget — checked
/// here, in the same order as the C++ `setData` path (size cap precedes the
/// transfer charge). On success returns the byte count.
///
/// Ordering note: because the byte count isn't known until `fill` runs, the
/// transfer budget is charged *after* the write rather than before it (the
/// pre-write gas charge in [`charged`] still bounds how often this runs). A
/// value rejected for being over-cap/over-budget may leave bytes in the guest
/// buffer, but they sit within the guest's own bounds and the guest must treat
/// a negative status as "don't read the buffer".
pub(crate) fn write_into(
caller: &mut Caller<'_, VmState<'_>>,
dst: i32,
cap: i32,
fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult<usize>,
) -> HostResult<i64> {
if dst < 0 || cap < 0 {
return Err(HostError::InvalidParams);
}
let (dst, cap) = (dst as usize, cap as usize);
let mem = memory(caller)?;
// Copy the shared `&dyn HostFunctions` out of the store data (references are
// Copy) so the data borrow ends before we borrow guest memory mutably.
let host: &dyn HostFunctions = caller.data().host;
let end = dst.checked_add(cap).ok_or(HostError::PointerOutOfBounds)?;
let out = mem
.data_mut(&mut *caller)
.get_mut(dst..end)
.ok_or(HostError::PointerOutOfBounds)?;
let n = fill(host, out)?;
if n > MAX_WASM_DATA_LEN {
return Err(HostError::DataFieldTooLarge);
}
if n > cap {
return Err(HostError::BufferTooSmall);
}
charge_transfer(caller.data(), n)?;
Ok(n as i64)
}
// 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_WASM_DATA_LEN <= 8 * 1024,
"read_write's input buffer is a stack array; keep MAX_WASM_DATA_LEN small"
);
/// Service a host call that reads an input region *and* writes an output region
/// of guest memory (e.g. `sha512_half`).
///
/// The input is copied into a fixed **stack** buffer — no heap allocation. It's
/// bounded by [`MAX_WASM_DATA_LEN`] (the 1 KiB field cap, checked before the
/// copy), so a plain `[u8; MAX_WASM_DATA_LEN]` array always fits; `&buf[..len]`
/// carries the length, so no wrapper type is needed. Keeping the input in a
/// stack local — rather than a borrow of the wasmi store — is what lets it
/// coexist with the output `&mut [u8]`: [`write_into`] can borrow guest memory
/// mutably for the output while `input` (borrowing the local) stays valid, with
/// no aliasing/split reasoning. The output half reuses [`write_into`] verbatim,
/// so the field-cap / buffer-fit / transfer policy is unchanged.
///
/// (The stack buffer is zero-initialized each call — one `memset` of the cap
/// size. That's the deliberately-simple PoC trade: it drops the per-call heap
/// allocation the old `Vec<u8>` path paid, at the price of a small fixed
/// zero-fill; a `MaybeUninit`/arrayvec buffer could drop that too.)
pub(crate) fn read_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<i64> {
if src < 0 || src_len < 0 {
return Err(HostError::InvalidParams);
}
let len = src_len as usize;
if len > MAX_WASM_DATA_LEN {
return Err(HostError::DataFieldTooLarge);
}
charge_transfer(caller.data(), len)?;
// Copy the input into a stack buffer, then release the (shared) store
// borrow before `write_into` takes it mutably for the output.
let mut buf = [0u8; MAX_WASM_DATA_LEN];
memory(caller)?
.read(&*caller, src as usize, &mut buf[..len])
.map_err(|_| HostError::PointerOutOfBounds)?;
let input = &buf[..len];
write_into(caller, dst, cap, |host, out| call(host, input, out))
}

View File

@@ -1,14 +1,5 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
mod abi;
mod register;
mod vm;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
pub use vm::run;

View File

@@ -0,0 +1,133 @@
use crate::abi::{AbiRet, charged, read_borrowed, read_write, to_wasm_i32, write_into};
use crate::vm::VmState;
use wasmi::{Caller, Linker};
use xrpl_host_functions::{HostError, HostFunctionSpec};
/// Import module namespace the guest imports host functions from
/// (`(import "host" "ldgr_index" ...)`).
const HOST_MODULE: &str = "host";
// ---------------------------------------------------------------------------
// Import registration
// ---------------------------------------------------------------------------
/// Register the PoC's host functions on `linker`, one per [`HostFn`] variant.
///
/// Driven by an exhaustive `match` over [`HostFn::ALL`]: adding a variant to
/// the ABI won't compile until it has an arm here (that's the "can't forget to
/// register" guarantee). Each arm charges gas once via [`charged`] — the sole
/// entry point for `charge` — and marshals its wasm scalars through
/// [`AbiArg`]/[`AbiRet`] before calling straight into the [`HostFunctions`]
/// trait object held in the [`Store`].
pub(crate) fn register_host_functions(linker: &mut Linker<VmState<'_>>) -> Result<(), String> {
fn link_err(e: wasmi::errors::LinkerError) -> String {
format!("register import: {e}")
}
// TODO: think on how to make it better
for &op in HostFunctionSpec::ALL {
match op {
HostFunctionSpec::GetLedgerSqn => linker.func_wrap(
HOST_MODULE,
op.spec().name,
|mut caller: Caller<'_, VmState<'_>>, out_ptr: i32, out_len: i32| -> i32 {
to_wasm_i32(charged(&mut caller, HostFunctionSpec::GetLedgerSqn, |c| {
// The host writes the serialized sequence number
// straight into the guest output region; `write_into`
// owns the bounds/cap/buffer/transfer policy.
write_into(c, out_ptr, out_len, |host, out| host.get_ledger_sqn(out))
}))
},
),
HostFunctionSpec::GetCurrentLedgerObjField => linker.func_wrap(
HOST_MODULE,
op.spec().name,
|mut caller: Caller<'_, VmState<'_>>,
field: i32,
out_ptr: i32,
out_len: i32|
-> i32 {
to_wasm_i32(charged(
&mut caller,
HostFunctionSpec::GetCurrentLedgerObjField,
|c| {
// The host writes the field's bytes straight into
// the guest output region (no owned `Vec` in
// between); `write_into` owns the policy.
write_into(c, out_ptr, out_len, |host, out| {
host.get_current_ledger_obj_field(field, out)
})
},
))
},
),
HostFunctionSpec::Sha512Half => linker.func_wrap(
HOST_MODULE,
op.spec().name,
|mut caller: Caller<'_, VmState<'_>>,
data_ptr: i32,
data_len: i32,
out_ptr: i32,
out_len: i32|
-> i32 {
to_wasm_i32(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),
)
}))
},
),
HostFunctionSpec::Trace => linker.func_wrap(
HOST_MODULE,
op.spec().name,
|mut caller: Caller<'_, VmState<'_>>,
msg_ptr: i32,
msg_len: i32,
data_ptr: i32,
data_len: i32,
as_hex: i32|
-> i32 {
to_wasm_i32(charged(&mut caller, HostFunctionSpec::Trace, |c| {
// Read `msg`/`data` straight out of guest memory — the
// slices alias linear memory, no owned copy (`trace`
// returns nothing, so there's no output-aliasing worry).
let host = c.data().host;
let msg = read_borrowed(c, msg_ptr, msg_len)?;
let data = read_borrowed(c, data_ptr, data_len)?;
let msg = core::str::from_utf8(msg).map_err(|_| HostError::Decoding)?;
host.trace(msg, data, as_hex != 0)?;
<() as AbiRet>::write((), c, ())
}))
},
),
HostFunctionSpec::TraceNum => linker.func_wrap(
HOST_MODULE,
op.spec().name,
|mut caller: Caller<'_, VmState<'_>>,
msg_ptr: i32,
msg_len: i32,
number: i64|
-> i32 {
to_wasm_i32(charged(&mut caller, HostFunctionSpec::TraceNum, |c| {
// `msg` aliases guest memory — no owned copy.
let host = c.data().host;
let msg = read_borrowed(c, msg_ptr, msg_len)?;
let msg = core::str::from_utf8(msg).map_err(|_| HostError::Decoding)?;
host.trace_num(msg, number)?;
<() as AbiRet>::write((), c, ())
}))
},
),
}
.map_err(link_err)?;
}
Ok(())
}

View File

@@ -0,0 +1,142 @@
use std::cell::Cell;
use std::sync::LazyLock;
use wasmi::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
use xrpl_host_functions::HostFunctions;
use crate::register::register_host_functions;
/// wasm linear-memory page size, fixed by the wasm spec (64 KiB).
const WASM_PAGE_BYTES: u32 = 64 * 1024;
/// Linear-memory page cap.
pub const MAX_MEMORY_PAGES: u32 = 128;
/// Byte form of [`MAX_MEMORY_PAGES`]: `128 * 65536 = 8_388_608` (8 MiB).
pub const MAX_MEMORY_BYTES: usize = (MAX_MEMORY_PAGES * WASM_PAGE_BYTES) as usize;
/// Per-run transfer-limit budget: total bytes that may cross the host/guest
/// boundary (via the `read_bytes` / `write_into` helpers in `abi.rs`) during
/// one [`run_escrow`] invocation. A budget separate from gas.
pub const TRANSFER_LIMIT_BYTES: u64 = 1 << 20;
/// State threaded through every host call, stored in the wasmi [`Store`].
pub struct VmState<'h> {
pub(crate) host: &'h dyn HostFunctions,
/// Enforces [`MAX_MEMORY_BYTES`] via `Store::limiter` (see `run_escrow`).
/// Lives in `VmState` (rather than as a standalone local) because the
/// limiter callback wasmi holds must be able to produce a `&mut` into it
/// from `&mut VmState`.
pub(crate) mem_limits: StoreLimits,
/// Remaining transfer-limit budget for this run (see
/// [`TRANSFER_LIMIT_BYTES`]); decremented in `abi.rs`'s `read_bytes` /
/// `write_into` by the number of bytes actually moved.
///
/// A `Cell`, not a plain `u64`: `AbiArg::read` (the guest -> host read
/// path) only has a shared `&Caller`, while `write_into` (the host ->
/// guest write path) has `&mut Caller` — both need to decrement this
/// counter, so it can't be an ordinary field mutated only through
/// `&mut`. The store (and this counter) is only ever touched from one
/// thread per invocation, so `Cell`'s lack of `Sync` is not an issue.
///
/// NOTE: the C++ `unalignedGas`/`FieldLocator` alignment-copy charge
/// (`HostFuncWrapper.cpp:44,390-397`) is deferred — the PoC has no
/// `FieldLocator` host functions yet to attach it to.
pub(crate) transfer_budget: Cell<u64>,
}
/// Outcome of running an escrow contract to completion.
#[derive(Debug)]
pub struct RunOutcome {
/// The value returned by the exported entry point (`finish`): `> 0` means
/// allow the escrow to finish.
pub result: i32,
/// Fuel (gas) consumed by the whole invocation — guest instructions plus
/// the per-call host charges.
pub fuel_used: u64,
}
/// The process-wide wasmi engine, built once on first use.
///
/// The engine's configuration is consensus-fixed and identical for every
/// invocation, so there is no reason to rebuild it per finish. A wasmi
/// [`Engine`] is an `Arc` internally (cheap to share, `Send + Sync`), and
/// modules compiled against it are per-invocation, so a single shared engine is
/// safe to reuse across concurrent [`run_escrow`] calls.
pub fn wasm_engine() -> &'static Engine {
static ENGINE: LazyLock<Engine> = LazyLock::new(build_wasm_engine);
&ENGINE
}
/// Build the wasmi engine with the sandboxing knobs the escrow VM requires.
/// (Unchanged from the original skeleton: a deterministic, minimal-feature
/// configuration with fuel metering on.)
fn build_wasm_engine() -> Engine {
let mut config = Config::default();
config.consume_fuel(true);
config.ignore_custom_sections(true);
config.wasm_mutable_global(false);
config.wasm_multi_value(false);
config.wasm_sign_extension(false);
config.wasm_saturating_float_to_int(false);
config.wasm_bulk_memory(false);
config.wasm_reference_types(false);
config.wasm_tail_call(false);
config.wasm_extended_const(false);
config.floats(false);
config.wasm_multi_memory(false);
config.wasm_custom_page_sizes(false);
config.wasm_memory64(false);
config.wasm_wide_arithmetic(false);
// TODO: enable option to reject wasm code containing start section after next wasmi release
Engine::new(&config)
}
/// Run a contract: compile `wasm`, give it `gas` fuel, service its host
/// calls through `host`, and call the exported `function_name`.
pub fn run<'h>(
wasm: &[u8],
gas: u64,
host: &'h dyn HostFunctions,
function_name: &str,
) -> Result<RunOutcome, String> {
let engine = wasm_engine();
let module = Module::new(engine, wasm).map_err(|e| format!("compile: {e}"))?;
let mem_limits = StoreLimitsBuilder::new()
.memory_size(MAX_MEMORY_BYTES)
.trap_on_grow_failure(true)
.build();
let mut store = Store::new(
engine,
VmState {
host,
mem_limits,
transfer_budget: Cell::new(TRANSFER_LIMIT_BYTES),
},
);
store.set_fuel(gas).map_err(|e| format!("set_fuel: {e}"))?;
// Registers the memory-page cap; also applied at instantiation time (an
// initial memory declared past the cap fails instantiation, same as a
// `memory.grow` past it traps at runtime).
store.limiter(|state| &mut state.mem_limits);
let mut linker = Linker::<VmState<'h>>::new(engine);
register_host_functions(&mut linker)?;
let instance = linker
.instantiate_and_start(&mut store, &module)
.map_err(|e| format!("instantiate: {e}"))?;
let finish = instance
.get_typed_func::<(), i32>(&store, function_name)
.map_err(|e| format!("no entry point '{function_name}': {e}"))?;
let result = finish
.call(&mut store, ())
.map_err(|e| format!("trap: {e}"))?;
let remaining = store.get_fuel().unwrap_or(0);
Ok(RunOutcome {
result,
fuel_used: gas.saturating_sub(remaining),
})
}

View File

@@ -0,0 +1,351 @@
# rippled fork — Rust WASM VM work
## What this branch is doing
We are on `Wasm-vm-redesign`: replacing the C++ wasmi **C-API** integration with a
Rust wasmi wrapper, written as **refined, production-ready code** built on the ideas
of the PoC — not a cleanup pass over the PoC itself.
`Rust_wasm_PoC` (and `Rust_wasm_PoC_benchmark`) are **reference branches**: the PoC
lives there, read-only, to be consulted for approach and prior art. Code copied
across from it is a starting point, not a baseline to preserve — the PoC's shapes,
comments and trade-offs are all open for redesign here.
The C-API path is already gone: commit `b7059deb9f` ("Remove wasmi dependency")
deleted `WasmVM.{h,cpp}`, `WasmiVM.h`, `HostFuncWrapper.cpp` and dropped the conan
`wasmi` package; `src/libxrpl/tx/wasm/WasmiVM.cpp` is now one big comment block kept
only for reference. Anything we need about the old semantics is 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`).
- `crates/xrpl-host-functions/``no_std` crate holding the ABI declaration:
`host_functions! { ... }` generates the `HostFunctions` trait + the
`HostFunctionSpec` enum (wasm import name + gas per function). Also `HostError`.
**This crate is the single source of truth for the ABI.**
- `crates/xrpl-host-functions-macros/` — the `host_functions!` proc macro.
- `crates/xrpl-wasm-vm/` — the wasmi wrapper: `vm.rs` (engine/store/run),
`abi.rs` (gas + transfer-limit + guest-memory marshaling), `register.rs`
(hand-written `Linker::func_wrap` per host function).
- `crates/xrpl-wasm-vm-ffi/` — cxx bridge to C++. **Still empty** (`mod ffi {}`);
nothing is wired to C++ yet.
- `include/xrpl/tx/wasm/`, `src/libxrpl/tx/wasm/` — C++ side: `HostFunc.h` (the
~60-method `HostFunctions` interface the ledger implements), `HostFuncImpl*.cpp`
(its implementations), `WasmCommon.h` (`HostFunctionError`, `Wmem`, `WasmTER`,
`FieldLocator`), `README.md` (ABI docs, worth reading — but stale in places, see below).
## Agreed direction (2026-07-28)
- **Nothing has been released yet.** We follow XLS-0102 for the *shape* of the ABI
(import names, signatures, error-code-as-negative-i32 convention, limits), but we
are free to fix behaviour that is simply wrong — we are not bound to reproduce the
deleted C++ implementation bug-for-bug.
- What XLS-0102 actually pins down is thinner than the C++ code implies: there is
**no error-code table in the spec** (only "negative return = error code"), so the
binding authority for the numeric codes is the guest SDK, not `HostFunctionError`.
The spec *does* say gas exhaustion "triggers immediate execution halting" — so
out-of-gas must **trap**, never return a code. It states a "1 MiB limit, per host
function call, on total data transfer across the WASM boundary" and a "1 KiB limit
… in a single host function call"; the C++ implementation made the 1 MiB a
per-invocation budget, which is stricter than a literal reading (unresolved).
- **Host-function registration stays hand-written** in `xrpl-wasm-vm/src/register.rs`
for now — generating it from `host_functions!` was tried and the macro got too
complicated. Reduce the per-function boilerplate with a small set of generic
adapters in `abi.rs` instead of codegen. (Revisited 2026-07-29 — see below. The
target is macro-emitted *typed shims* rather than full codegen, but it is a later
refactor, not a prerequisite.)
## C-level ABI compatibility (2026-07-29)
### The requirement
Guests must not be limited to Rust. C — and any language targeting wasm32 — must be
able to call host functions.
### This is already satisfied at the wire, by construction
WASM imports can only carry `i32`/`i64`/`f32`/`f64`. There is no way to expose a
non-C-expressible host function. Proof already in-tree, a plain C guest with no Rust
anywhere:
```c
// src/test/app/wasm_fixtures/ledgerSqn.c:3
int32_t ldgr_index(uint8_t *, int32_t);
```
`register.rs` is what defines the C signature: wasmi derives the `FuncType` from the
closure's **parameter and result Rust types** (each must implement `WasmTy`);
`Caller<'_, VmState<'_>>` is special-cased and excluded. Parameter *names* are not
part of the ABI. The full contract a C author binds against is:
1. import module name,
2. import (field) name,
3. ordered param `ValType`s,
4. result `ValType`,
5. the return-value semantics (negative = error code; non-negative meaning varies —
see "Return conventions are not uniform" below).
### What the C++ path had that the Rust redesign lost
The deleted C++ code declared each host function as a **literal C function type**:
```cpp
// include/xrpl/tx/wasm/HostFuncWrapper.h
using getLedgerSqn_proto = int32_t(uint8_t*, int32_t);
using trace_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, int32_t);
using traceNum_proto = int32_t(uint8_t const*, int32_t, int64_t);
```
`WasmImpArgs`/`WasmImpRet` (`include/xrpl/tx/wasm/WasmImportsHelper.h:41-84`) mapped
pointer/`int32_t``WtI32`, `int64_t``WtI64`, and `static_assert`-ed on anything
else. **C-expressibility was compile-enforced — you could not declare a host function
that wasn't C-callable.**
Caveat worth remembering: `_proto` was a *second, hand-maintained* declaration
alongside the virtual method in `HostFunc.h`. `WasmImpArgs` asserted `_proto` was
C-shaped; nothing checked that `_proto` matched the method it wrapped. That pairing
was hand-synced in `HostFuncWrapper.cpp`.
In the Rust redesign the wasm signature exists only as an emergent property of how
someone hand-typed a closure in `register.rs`. Nothing prevents a future arm from
omitting an out-pair, and nothing tells a C author what the signature is.
### Decision: the source of truth does not move
`crates/xrpl-host-functions/` stays the one declaration. C compatibility adds a
**third output** next to the trait and the spec enum — not a second input. The C
header becomes a *generated, checked-in artifact* with a CI diff. One generated
declaration that cannot drift is strictly stronger than two explicit ones that can.
"Explicit vs hidden" is the wrong axis; **"derivable and emitted"** is the right one.
C authors read a header — they do not read the macro.
### The lowering table (the missing rule)
The existing DSL vocabulary already implies this; it was simply never written down.
That is the entire gap.
```
params:
i32, bool -> i32 (bool: nonzero = true)
i64 -> i64
&[u8], &str -> i32 ptr, i32 len const uint8_t*, int32_t
returns:
[u8; N], Vec<u8> -> appends i32 out_ptr, i32 out_len; result i32 = bytes written
i32, bool -> no out params; result i32 = the value
() -> no out params; result i32 = 0
```
Total and unambiguous. **The macro must reject any type not in this table** — that is
`WasmImpArgs`' `static_assert`, restored, and it is what guarantees the C API is
always surfaceable.
**Validation** — all five current declarations (`xrpl-host-functions/src/lib.rs:87-107`)
lower to exactly the deleted C++ `_proto` aliases:
| Declaration | Derived C | C++ `_proto` |
|---|---|---|
| `fn get_ledger_sqn() -> [u8; 4]` | `int32_t(uint8_t*, int32_t)` | `getLedgerSqn_proto` ✓ |
| `fn get_current_ledger_obj_field(field: i32) -> Vec<u8>` | `int32_t(int32_t, uint8_t*, int32_t)` | `getTxField_proto` ✓ |
| `fn sha512_half(data: &[u8]) -> [u8; 32]` | `int32_t(const uint8_t*, int32_t, uint8_t*, int32_t)` | ✓ |
| `fn trace(msg: &str, data: &[u8], as_hex: bool)` | `int32_t(const uint8_t*, int32_t, const uint8_t*, int32_t, int32_t)` | `trace_proto` ✓ |
| `fn trace_num(msg: &str, number: i64)` | `int32_t(const uint8_t*, int32_t, int64_t)` | `traceNum_proto` ✓ |
**Discipline the table requires**: byte outputs must be spelled as arrays.
`get_ledger_sqn` is correctly `-> [u8; 4]` (C++ writes 4 LE bytes and returns 4 — it
does *not* return the sequence number). By the same rule `float_to_int` must be
declared `-> [u8; 8]`, never `-> i64`. A scalar return type means value-in-the-return-
register (`get_tx_array_len(field: i32) -> i32`, `nft_flags`, `float_cmp`, `cache_le`,
`check_sig`, `amendment_enabled`).
### Closing the drift gap between `register.rs` and the generated header
**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`), not `Definition::Extern`
(doc comment at `:335`). `Definition::ty()` exists at `:171` and would give the
`FuncType`, but `Definition` and `get_definition` are private. So "assert
`Func::ty()` equals the spec" is **not available**.
Guarantee ladder:
| 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 emitting the *body*:
```rust
// generated by host_functions!
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)
}
```
`register.rs` keeps its hand-written bodies but becomes constrained:
```rust
HostFunctionSpec::Sha512Half => link_sha512_half(linker,
|mut caller, data_ptr, data_len, out_ptr, out_len| { /* logic, unchanged */ }),
```
Wrong arity, wrong scalar type or wrong return is now a **compile error**. The same
lowering table emits both `Sha512HalfFn` and
`int32_t sha512_half(const uint8_t*, int32_t, uint8_t*, int32_t);`, so they cannot
drift. That type alias is the regenerated `_proto` — the artifact C++ had, now derived
from the single source of truth instead of maintained beside it.
*Constraint*: `fn` pointers only accept non-capturing closures. Every arm in
`register.rs` today is non-capturing. If one ever needs to capture, that shim can take
`impl Fn(...) + Send + Sync + 'static` instead — weaker inference, same guarantee.
**Belt-and-braces (cheap, worth having anyway)**: a probe-module test. Synthesize a
WAT module from the spec table that imports every host function with its declared
type, then `linker.instantiate()` it. A signature mismatch fails instantiation. This
is the only check that also catches module-name and missing-import mistakes, and it
tests the *guest's* view end-to-end.
### Mechanism note: why the PoC's value-returning trait is the right shape
Generated or type-checked registration needs one uniform phase order:
> lift inputs with `&Caller` → call the host → lower outputs with `&mut Caller`
The uncommitted `write_into` / `HostResult<usize>` fill-the-guest-buffer code fights
this: it hands the host impl a `&mut [u8]` **into guest memory** while inputs also
alias guest memory. That is why `read_write` has to memcpy inputs into a
`[0u8; MAX_WASM_DATA_LEN]` stack array first — and that workaround does not
generalize, because `credential_keylet`, `check_sig` and `paychan_keylet` each take
three byte inputs.
The fix is for the host to write into a **host-side scratch buffer** that the dispatch
adapter owns, with a single copy into guest memory afterwards. Not guest memory → no
aliasing → no scratch-per-input. This is what C++ did (`std::expected<Bytes, …>` +
`setData`), so gas/behaviour parity is preserved, and it is essentially the PoC's
original value-returning trait plus `HostResult<T>` for the error channel.
Cost is roughly a wash, not a straight loss of the zero-extra-copy work:
- `sha512_half` — today: ≤1 KiB input copied to stack + 32 bytes written ≈ 1056 bytes
moved. New: input borrowed zero-copy, 32 bytes copied out. **Better.**
- `get_tx_field` (no byte input, ≤1 KiB output) — today 1024 direct; new 1024 + 1024.
**Worse.**
It also fixes a real wart: `write_into` checks `n > cap` *after* `fill` has already
written, so a rejected call leaves garbage in the guest buffer. C++ `setData` checked
before the memcpy.
### Status: deferred
**Not a blocker.** Get the VM compiling and working first; the typed shims, generated
header and probe-module test are a follow-up refactor once there is working code.
## Open ABI questions and interop risks (2026-07-29)
Found while auditing the guest SDK (`~/Documents/rust/xrpl-wasm-stdlib`, checkout
`435a091f`) against this fork. All unresolved.
1. **Import module name.** The old C++ VM ignored it entirely —
`wasm_importtype_module()` is commented out at `src/libxrpl/tx/wasm/WasmiVM.cpp:429-431`
and only the field name is looked up. `register.rs:8` now enforces `"host"`. The SDK
and the fork's own fixture (`src/test/app/wasm_fixtures/codecov_tests/src/host_bindings_loose.rs:20`)
use `"host_lib"`. Plain clang emits `"env"` unless annotated. `"host"` currently
matches nothing that exists.
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 rippled's `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`,
`unsafe { core::mem::transmute(code) }` — valid only for `-1..=-20`. Our `HostError`
adds `NoRuntime = -21`, `OutOfGas = -22`, `OutOfTransferLimit = -23`, and
`to_wasm_i32` returns all of them as codes.
*Fix that solves this and the XLS-0102 halting requirement together*: make
host-fatal errors **traps**. The closure returns `Result<i32, wasmi::Error>`; the
wasm signature is unchanged (still `(…) -> i32`), and the guest-visible table
collapses back to exactly `-1..-20`. This also restores the C++ two-channel design
(`"HfOutOfGas"` / `"HfInternal"` trap strings vs negative returns).
*Still open*: is `OutOfTransferLimit` soft or fatal? The guest has no code for it —
`-11` is `InvalidDecoding` there but `OutOfTransferLimit` in `WasmCommon.h:47`.
Fatal is the only resolution that needs no SDK change.
4. **`-1` collides semantically**: host `Unimplemented` vs guest `InternalError`.
5. **`float_to_mant_exp` byte count.** Host returns **12** (8 mantissa + 4 exponent,
`HostFuncWrapper.cpp:497` at `b7059deb9f^`); the guest doc says 8. The guest's
`match_result_code_with_expected_bytes` **panics** on a non-negative mismatch.
6. **Return conventions are not uniform** — six of them, today documented only in
comments: 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``0` equal, `1` first >
second, `2` first < second).
7. **The SDK's drift checker is silently broken.** `tools/compareHostFunctions.js`
regex-parses `WasmVM.cpp` and `HostFuncWrapper.h`, both deleted at HEAD. A
generated header would give it a stable target again.
Also noted: `include/xrpl/tx/wasm/README.md` is stale its worked example uses the
long name `get_ledger_sqn` where the code registered `ldgr_index`, and it references
`detail/WasmVM.cpp`, `detail/HostFuncWrapper.cpp` and `ParamsHelper.h`, none of which
exist (the helper is `WasmImportsHelper.h`).
## Reference points from the deleted C++ path
Import names and gas costs are ABI; the rest below is *evidence of prior behaviour*,
useful for comparison and for the gas assertions in `Wasm_test.cpp` not gospel.
- Import names + per-call gas: `git show b7059deb9f^:src/libxrpl/tx/wasm/WasmVM.cpp`
(`setCommonHostFunctions`, 64 entries + `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`**).
- Host-fatal conditions are **traps**, not return codes: out-of-gas and internal
errors threw `hfErrOutOfGas` / `hfErrInternal` trap `tecOUT_OF_GAS` /
`tecINTERNAL`. Only the transfer limit is a soft, guest-visible failure.
- Limits: `maxPages = 128` (8 MiB), `kMaxWasmDataLength = 1024`,
`kWasmTransferLimit = 1 << 20` (both in `include/xrpl/protocol/Protocol.h`).
- Transfer limit is charged for bytes *actually copied*: hostguest writes
(`setData`) and typed reads that materialize a host object (uint256, AccountID,
Currency, Asset) plus unaligned `FieldLocator` copies (+`unalignedGas = 50`).
Plain slice/string reads (`trace` msg/data, `sha512_half` input) are **not** charged.
- Entry point is `escrow_finish` (`escrowFunctionName`); gas `-1` meant unlimited,
gas `<= 0` meant `temBAD_AMOUNT`; on out-of-gas the reported cost is the full limit.
Positive return = conditions met; `0` or negative = reject.
- Engine config (fuel on, floats off, all post-MVP proposals off) is in the commented
`WasmiVM.cpp` `WasmiEngine::init()`; `crates/xrpl-wasm-vm/src/vm.rs` mirrors it.
- wasmi's fuel table is consensus input pin the wasmi version deliberately
(currently `wasmi = "1.1.0"`). `src/test/app/Wasm_test.cpp` asserts exact gas numbers
(e.g. 29'502) and is the best parity oracle we have.
## Build / test loop
- Fast: `cd crates && cargo check --workspace --all-targets`, `cargo test --workspace`,
`cargo clippy --workspace --all-targets`.
- Full C++↔Rust: normal CMake build, then `xrpl_tests` (`src/test/app/Wasm_test.cpp`,
`HostFuncImpl_test.cpp`).
- VCS is **jj** (`jj st`, `jj log`), not raw git, for local work.
## Current state (2026-07-29)
`crates/` does **not** compile: the macro-generated trait (value-returning,
infallible) and the uncommitted VM code (fill-caller's-buffer, `HostResult<usize>`,
`&dyn`) are two different ABI shapes. Every call site in `register.rs` is affected,
as are the macro's own doctests and `xrpl-host-functions/tests/generated_abi.rs`
(which still use `&mut self` and value returns).
Resolving that shape is the immediate work. Per the mechanism note above, the
value-returning direction (plus `HostResult<T>`) is the one that composes with
typed/generated registration; the fill-the-guest-buffer shape is what fights it.
Deferred to a later refactor, once there is working code: macro-emitted `link_*`
shims, the generated C header, and the probe-module conformance test.