mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 14:20:56 +00:00
WIP
This commit is contained in:
163
crates/Cargo.lock
generated
163
crates/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -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" }
|
||||
|
||||
247
crates/xrpl-wasm-vm/src/abi.rs
Normal file
247
crates/xrpl-wasm-vm/src/abi.rs
Normal 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
133
crates/xrpl-wasm-vm/src/register.rs
Normal file
133
crates/xrpl-wasm-vm/src/register.rs
Normal 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(())
|
||||
}
|
||||
142
crates/xrpl-wasm-vm/src/vm.rs
Normal file
142
crates/xrpl-wasm-vm/src/vm.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
351
docs/claude/redesign_impl.md
Normal file
351
docs/claude/redesign_impl.md
Normal 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*: 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<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.
|
||||
Reference in New Issue
Block a user