diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 871b6bab8a..8654aa6577 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -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" diff --git a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs index 41e430da69..a8612aa459 100644 --- a/crates/xrpl-host-functions-macros/src/parsed_host_function.rs +++ b/crates/xrpl-host-functions-macros/src/parsed_host_function.rs @@ -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)* diff --git a/crates/xrpl-wasm-vm/Cargo.toml b/crates/xrpl-wasm-vm/Cargo.toml index 6ec8dc2cad..b6865b8c4f 100644 --- a/crates/xrpl-wasm-vm/Cargo.toml +++ b/crates/xrpl-wasm-vm/Cargo.toml @@ -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" } diff --git a/crates/xrpl-wasm-vm/src/abi.rs b/crates/xrpl-wasm-vm/src/abi.rs new file mode 100644 index 0000000000..e8e627e875 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/abi.rs @@ -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; 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; +} + +impl AbiRet for () { + type Out = (); + fn write(self, _c: &mut Caller<'_, VmState<'_>>, _o: ()) -> HostResult { + Ok(0) + } +} +impl AbiRet for u32 { + type Out = (); + fn write(self, _c: &mut Caller<'_, VmState<'_>>, _o: ()) -> HostResult { + 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, +) -> HostResult { + charge(caller, op.spec().gas)?; + body(caller) +} + +pub(crate) fn to_wasm_i32(r: HostResult) -> i32 { + match r { + Ok(v) => v as i32, + Err(e) => e.code(), + } +} +#[allow(dead_code)] +pub(crate) fn to_wasm_i64(r: HostResult) -> 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(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(caller: &Caller<'_, T>) -> Result { + 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; 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, +) -> HostResult { + 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` 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, +) -> HostResult { + 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)) +} diff --git a/crates/xrpl-wasm-vm/src/lib.rs b/crates/xrpl-wasm-vm/src/lib.rs index b93cf3ffd9..50936e7036 100644 --- a/crates/xrpl-wasm-vm/src/lib.rs +++ b/crates/xrpl-wasm-vm/src/lib.rs @@ -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; diff --git a/crates/xrpl-wasm-vm/src/register.rs b/crates/xrpl-wasm-vm/src/register.rs new file mode 100644 index 0000000000..7d5737ab15 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/register.rs @@ -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>) -> 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(()) +} diff --git a/crates/xrpl-wasm-vm/src/vm.rs b/crates/xrpl-wasm-vm/src/vm.rs new file mode 100644 index 0000000000..6cbae96719 --- /dev/null +++ b/crates/xrpl-wasm-vm/src/vm.rs @@ -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, +} + +/// 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 = 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 { + 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::>::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), + }) +} diff --git a/docs/claude/redesign_impl.md b/docs/claude/redesign_impl.md new file mode 100644 index 0000000000..9ec4f202c2 --- /dev/null +++ b/docs/claude/redesign_impl.md @@ -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^:` — 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 -> 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` | `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; + +pub fn link_sha512_half(l: &mut Linker>, 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` 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` + +`setData`), so gas/behaviour parity is preserved, and it is essentially the PoC's +original value-returning trait plus `HostResult` 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`; 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*: host→guest 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`, +`&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`) 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.