Add check to vm

This commit is contained in:
Sergey Kuznetsov
2026-08-04 13:53:25 +01:00
parent f2271ecc03
commit 8ecb77dcfb
7 changed files with 600 additions and 31 deletions

View File

@@ -16,10 +16,12 @@
)]
mod abi;
mod preflight;
mod region;
mod register;
mod vm;
pub use preflight::{CheckError, check};
pub use vm::{
MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, RunError, RunFailure, RunOutcome,
TRANSFER_LIMIT_BYTES, run,

View File

@@ -0,0 +1,134 @@
//! Screening a contract before it reaches the ledger.
//!
//! [`check`] answers whether [`crate::run`] would refuse a module before the
//! guest's first instruction — the three stages a caller maps to a malformed
//! transaction rather than to a failed one. It needs **no host, no store and no
//! gas**: everything it reads is a property of the compiled module. That is what
//! makes it callable from a transaction's preflight, which has no ledger to serve
//! host calls from.
//!
//! Two things it deliberately does not screen. A module exporting no linear
//! memory passes: a contract that makes no host call needs none, and one that
//! does is refused at the call and charged for what it burned. A start section
//! passes: it is guest code, and executing it is the one thing a check must not
//! do.
use std::fmt;
use wasmi::{ExternType, FuncType, Module, ValType};
use xrpl_host_functions::HostFunctionSpec;
use crate::register::HOST_MODULE;
use crate::vm::compile;
/// Why a module cannot be run. One variant per stage, since the caller maps the
/// stages separately.
#[derive(Debug)]
pub enum CheckError {
/// `wasm` is not a valid module under this engine's configuration.
Compile(String),
/// An import no engine of this ABI defines: another module namespace, a name
/// that is not a host function, or one imported as something other than a
/// function.
Import(String),
/// No export named `function_name` with signature `() -> i32`.
EntryPoint(String),
}
impl fmt::Display for CheckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CheckError::Compile(detail) => write!(f, "compile: {detail}"),
CheckError::Import(detail) => write!(f, "import: {detail}"),
// The detail says which of the entry point's failures this is, since
// "no entry point" would be wrong for an export of the wrong type.
CheckError::EntryPoint(detail) => write!(f, "{detail}"),
}
}
}
/// Screen `wasm`: it must compile, import only what the engine serves, and export
/// `function_name` as `() -> i32`.
pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
let module = compile(wasm).map_err(CheckError::Compile)?;
check_imports(&module)?;
check_entry_point(&module, function_name)
}
/// Every import must be one the linker defines.
///
/// The set is [`HostFunctionSpec::ALL`], which is also what
/// [`crate::register::register_host_functions`] iterates — so a check and a run
/// cannot disagree about which names exist, and adding a host function extends
/// both at once. The one thing this does not compare is the *signature*, which
/// still parts a module from the engine at instantiation.
fn check_imports(module: &Module) -> Result<(), CheckError> {
for import in module.imports() {
let name = import.name();
if import.module() != HOST_MODULE {
return Err(CheckError::Import(format!(
"'{}::{name}' is not from '{HOST_MODULE}'",
import.module()
)));
}
if !HostFunctionSpec::ALL
.iter()
.any(|op| op.wasm_name() == name)
{
return Err(CheckError::Import(format!("no host function '{name}'")));
}
if !matches!(import.ty(), ExternType::Func(_)) {
return Err(CheckError::Import(format!(
"'{HOST_MODULE}::{name}' is not a function"
)));
}
}
Ok(())
}
fn check_entry_point(module: &Module, name: &str) -> Result<(), CheckError> {
match module.get_export(name) {
Some(ExternType::Func(ty)) if is_entry_point(&ty) => Ok(()),
found => Err(CheckError::EntryPoint(entry_point_fault(found, name))),
}
}
/// The entry point's type: nothing in, one `i32` out — what [`crate::run`]'s
/// `get_typed_func::<(), i32>` accepts.
fn is_entry_point(ty: &FuncType) -> bool {
ty.params().is_empty() && matches!(ty.results(), [ValType::I32])
}
/// How an entry-point lookup failed, in the words both stages use: a check and a
/// run describe the same module the same way, and "no entry point" would send a
/// contract author looking for a function they already have.
pub(crate) fn entry_point_fault(found: Option<ExternType>, name: &str) -> String {
match found {
Some(ExternType::Func(_)) => {
format!("entry point '{name}' has the wrong signature, expected '() -> i32'")
}
Some(_) => format!("export '{name}' is not a function"),
None => format!("no entry point '{name}'"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Both halves of the type are load-bearing, and neither is checked anywhere
/// a module cannot reach.
#[test]
fn the_entry_point_type_is_nothing_in_and_one_i32_out() {
assert!(is_entry_point(&FuncType::new([], [ValType::I32])));
for wrong in [
FuncType::new([], []),
FuncType::new([], [ValType::I64]),
FuncType::new([ValType::I32], [ValType::I32]),
FuncType::new([], [ValType::I32, ValType::I32]),
] {
assert!(!is_entry_point(&wrong), "{wrong:?}");
}
}
}

View File

@@ -6,7 +6,7 @@ use xrpl_host_functions::{HostError, HostFunctionSpec};
/// The module name the guest imports under (`(import "host_lib" "ldgr_index" …)`),
/// as the guest SDK and this fork's fixtures spell it.
const HOST_MODULE: &str = "host_lib";
pub(crate) const HOST_MODULE: &str = "host_lib";
/// Register the host functions on `linker`, one per [`HostFunctionSpec`] variant.
///

View File

@@ -2,12 +2,13 @@ use std::cell::Cell;
use std::fmt;
use std::sync::LazyLock;
use wasmi::{
Config, Engine, Export, Extern, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder,
Config, Engine, Export, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder,
TrapCode,
};
use xrpl_host_functions::{HostError, HostFunctions};
use crate::abi::FatalHostError;
use crate::preflight::entry_point_fault;
use crate::register::register_host_functions;
/// wasm linear-memory page size, fixed by the wasm spec (64 KiB).
@@ -257,6 +258,15 @@ fn build_wasm_engine() -> Engine {
Engine::new(&config)
}
/// Compile `wasm` for this engine.
///
/// The one path to a [`Module`]: the configuration is what decides whether a
/// contract is valid at all, so [`run`] and [`crate::check`] must not be able to
/// compile against different ones.
pub(crate) fn compile(wasm: &[u8]) -> Result<Module, String> {
Module::new(wasm_engine(), wasm).map_err(|e| e.to_string())
}
/// 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>(
@@ -266,8 +276,8 @@ pub fn run<'h>(
function_name: &str,
) -> Result<RunOutcome, RunFailure> {
let engine = wasm_engine();
let module = Module::new(engine, wasm)
.map_err(|e| RunFailure::owing_nothing(RunError::Compile(e.to_string())))?;
let module =
compile(wasm).map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?;
let mem_limits = StoreLimitsBuilder::new()
.memory_size(MAX_MEMORY_BYTES)
@@ -305,11 +315,11 @@ pub fn run<'h>(
let function = match instance.get_typed_func::<(), i32>(&store, function_name) {
Ok(function) => function,
Err(e) => {
let error = RunError::EntryPoint(entry_point_detail(
instance.get_export(&store, function_name),
function_name,
&e,
));
let found = instance
.get_export(&store, function_name)
.map(|export| export.ty(&store));
let error =
RunError::EntryPoint(format!("{}: {e}", entry_point_fault(found, function_name)));
return Err(failed(&store, gas, error));
}
};
@@ -326,16 +336,6 @@ pub fn run<'h>(
Ok(RunOutcome { result, fuel_used })
}
fn entry_point_detail(export: Option<Extern>, name: &str, error: &wasmi::Error) -> String {
match export {
Some(Extern::Func(_)) => {
format!("entry point '{name}' has the wrong signature, expected '() -> i32': {error}")
}
Some(_) => format!("export '{name}' is not a function: {error}"),
None => format!("no entry point '{name}': {error}"),
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,401 @@
//! What screening refuses, and that it refuses nothing a run would have served.
//!
//! `check` reaches its verdict from the compiled module alone, so these tests take
//! no host — except the ones that put the same module through `run` to compare the
//! two.
mod support;
use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module};
use xrpl_host_functions::HostFunctionSpec;
use xrpl_wasm_vm::{CheckError, MAX_MEMORY_PAGES, RunError};
/// Assert which stage screening refused a module at, because the caller maps the
/// stages separately. The error comes back out for the tests that also read its
/// message.
macro_rules! assert_stage {
($refusal:expr, $stage:pat) => {{
let refusal = $refusal;
assert!(
matches!(refusal, $stage),
concat!("expected a ", stringify!($stage), " refusal, got: {}"),
refusal
);
refusal
}};
}
/// Screens `wat`, which must assemble.
fn check(wat: &str) -> Result<(), CheckError> {
xrpl_wasm_vm::check(&assemble(wat), ENTRY)
}
fn refusal(wat: &str) -> CheckError {
check(wat).expect_err(&format!("expected this module to be refused:\n{wat}"))
}
fn passes(wat: &str) {
if let Err(refusal) = check(wat) {
panic!("expected this module to pass, but: {refusal}\n{wat}");
}
}
// ---------------------------------------------------------------------------
// Compiling
// ---------------------------------------------------------------------------
/// A contract that imports a host function, exports its memory and exports the
/// entry point is what screening is looking for.
#[test]
fn a_runnable_contract_passes() {
passes(&module(
&[import::LDGR_INDEX, ONE_PAGE],
"(call $ldgr_index (i32.const 0) (i32.const 4))",
));
}
/// Bytes that are not a wasm module at all.
#[test]
fn garbage_does_not_pass() {
for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] {
let refusal = xrpl_wasm_vm::check(bytes, ENTRY).expect_err("garbage must not pass");
assert_stage!(refusal, CheckError::Compile(_));
}
}
/// Screening takes wasm binaries, and text is not one — the same rule the VM
/// applies, from the same `wasmi` built without its `wat` feature. Turning that
/// feature on would make this transaction blob valid at both ends.
#[test]
fn a_text_format_module_does_not_pass() {
let text = module(&[ONE_PAGE], "(i32.const 0)");
let refusal =
xrpl_wasm_vm::check(text.as_bytes(), ENTRY).expect_err("text must not pass as a module");
assert_stage!(refusal, CheckError::Compile(_));
// The same module, assembled first, passes: the text is sound and only the
// format was refused.
passes(&text);
}
/// A feature the engine disables is refused here too, because both stages compile
/// against the one engine. `vm_limits.rs` walks every disabled feature; this pins
/// that screening sees the same configuration.
#[test]
fn a_disabled_feature_does_not_pass() {
let refusal = refusal(&module(
&[ONE_PAGE],
"(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)",
));
let refusal = assert_stage!(refusal, CheckError::Compile(_)).to_string();
assert!(refusal.contains("floating-point"), "{refusal}");
}
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
/// Every host function the ABI declares, spelled as a guest imports it. The count
/// is asserted against the ABI so a function added to it cannot be left out here.
const ALL_IMPORTS: [&str; 5] = [
import::LDGR_INDEX,
import::HOME_LE_FIELD,
import::SHA512_HALF,
import::TRACE,
import::TRACE_NUM,
];
#[test]
fn every_declared_host_function_may_be_imported() {
assert_eq!(
ALL_IMPORTS.len(),
HostFunctionSpec::ALL.len(),
"the ABI gained a host function with no import declaration in this test"
);
let mut parts = ALL_IMPORTS.to_vec();
parts.push(ONE_PAGE);
passes(&module(&parts, "(i32.const 0)"));
}
/// A module may import fewer host functions than are registered, but not more.
#[test]
fn an_unknown_host_function_does_not_pass() {
let refusal = refusal(&module(
&[
r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#,
ONE_PAGE,
],
"(call $f (i32.const 0))",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(
refusal.contains("no host function 'no_such_function'"),
"{refusal}"
);
}
/// Host functions live under one module name — `host_lib` — and an import naming
/// another is refused even when the function name is real. `env` is in the list
/// because that is what plain clang emits.
#[test]
fn an_import_from_another_module_does_not_pass() {
for module_name in ["host", "env", ""] {
let refusal = refusal(&module(
&[
&format!(
r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"#
),
ONE_PAGE,
],
"(call $f (i32.const 0) (i32.const 4))",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(refusal.contains("is not from 'host_lib'"), "{refusal}");
}
}
/// A host function's name imported as something other than a function. The engine
/// defines it as a function and nothing else, so this does not link either.
#[test]
fn a_host_function_imported_as_a_global_does_not_pass() {
let refusal = refusal(&module(
&[
r#"(import "host_lib" "ldgr_index" (global $g i32))"#,
ONE_PAGE,
],
"(global.get $g)",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(
refusal.contains("'host_lib::ldgr_index' is not a function"),
"{refusal}"
);
}
/// The signature is the one part of an import screening does not compare, so a
/// module that will not link can still pass. Recorded here because it is the gap
/// this stage leaves, not because it is wanted.
#[test]
fn an_import_with_the_wrong_signature_still_passes() {
let wat = module(
&[
r#"(import "host_lib" "ldgr_index" (func $f (param i64 i64) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
);
passes(&wat);
let host = FakeHost::new();
let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY)
.expect_err("a mistyped import must not link");
assert!(
matches!(failure.error, RunError::Instantiate(_)),
"{failure}"
);
}
// ---------------------------------------------------------------------------
// The entry point
// ---------------------------------------------------------------------------
#[test]
fn a_missing_entry_point_does_not_pass() {
let refusal = refusal(
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#,
);
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(refusal, "no entry point 'finish'");
}
/// The entry point is looked up by the name the caller asks for, as a run looks it
/// up: screening a contract for one entry point says nothing about another.
#[test]
fn the_entry_point_is_the_name_the_caller_gives() {
let wasm = assemble(
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#,
);
assert!(xrpl_wasm_vm::check(&wasm, "other").is_ok());
assert!(xrpl_wasm_vm::check(&wasm, ENTRY).is_err());
}
/// Both halves of the entry point's type are screened: a module returning the
/// wrong thing, or taking anything at all, would fail the run's typed lookup.
#[test]
fn an_entry_point_of_the_wrong_type_does_not_pass() {
for (signature, body) in [
("(result i64)", "(i64.const 0)"),
("(param i32) (result i32)", "(i32.const 0)"),
("", "(nop)"),
] {
let refusal = refusal(&format!(
r#"(module (memory (export "memory") 1)
(func (export "finish") {signature} {body}))"#
));
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(
refusal, "entry point 'finish' has the wrong signature, expected '() -> i32'",
"{signature}"
);
}
}
/// An export of the entry point's name that is not a function at all is a third
/// case, and named as such: nothing is missing and no signature is wrong.
#[test]
fn an_entry_point_that_is_not_a_function_does_not_pass() {
let refusal = refusal(
r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#,
);
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(refusal, "export 'finish' is not a function");
}
// ---------------------------------------------------------------------------
// Agreement with a run
// ---------------------------------------------------------------------------
/// A module with no linear memory to export passes. A contract that makes no host
/// call needs none, and one that does is refused at the call and charged — a
/// runtime fault, not a malformed module.
#[test]
fn a_module_exporting_no_memory_passes() {
let wat = r#"(module (func (export "finish") (result i32) (i32.const 0)))"#;
passes(wat);
let host = FakeHost::new();
assert_eq!(
xrpl_wasm_vm::run(&assemble(wat), PLENTY_OF_GAS, &host, ENTRY)
.expect("a module that calls no host function needs no memory")
.result,
0
);
}
/// Modules spanning what screening decides, each also put through a run.
fn modules() -> Vec<(&'static str, String)> {
vec![
(
"a runnable contract",
module(&[import::LDGR_INDEX, ONE_PAGE], "(i32.const 0)"),
),
(
"a contract that traps",
module(&[ONE_PAGE], "(unreachable)"),
),
(
"a disabled feature",
module(&[ONE_PAGE], "(i32.extend8_s (i32.const 1))"),
),
(
"an unknown host function",
module(
&[
r#"(import "host_lib" "nope" (func $f (result i32)))"#,
ONE_PAGE,
],
"(call $f)",
),
),
(
"an import from another module",
module(
&[
r#"(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
),
),
(
"a host function imported as a global",
module(
&[r#"(import "host_lib" "trace" (global $g i32))"#, ONE_PAGE],
"(global.get $g)",
),
),
(
"no entry point",
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#
.to_string(),
),
(
"an entry point of the wrong type",
r#"(module (memory (export "memory") 1)
(func (export "finish") (result i64) (i64.const 0)))"#
.to_string(),
),
]
}
/// Screening refuses a module exactly when a run would refuse it at one of the
/// three stages screening covers — nothing it rejects would have run, and nothing
/// it passes stops before the entry point is called. The exceptions are the ones
/// [`what_static_screening_cannot_see`] lists.
#[test]
fn screening_and_a_run_agree() {
let host = FakeHost::new();
for (label, wat) in modules() {
let wasm = assemble(&wat);
let refused_early = match xrpl_wasm_vm::run(&wasm, PLENTY_OF_GAS, &host, ENTRY) {
Err(failure) => matches!(
failure.error,
RunError::Compile(_) | RunError::Instantiate(_) | RunError::EntryPoint(_)
),
Ok(_) => false,
};
assert_eq!(
xrpl_wasm_vm::check(&wasm, ENTRY).is_err(),
refused_early,
"{label}"
);
}
}
/// The gap, listed rather than described. A start section is guest code, and
/// running it is what screening must not do; a memory the module keeps to itself
/// is not in its exports. Both leave a module that passes screening and then fails
/// to instantiate, which is why a run's own refusal cannot be treated as the
/// node's fault.
#[test]
fn what_static_screening_cannot_see() {
let host = FakeHost::new();
for (label, wat) in [
(
"a start section that traps",
format!(
r#"(module {ONE_PAGE}
(func $init (unreachable))
(start $init)
(func (export "finish") (result i32) (i32.const 0)))"#
),
),
(
"an unexported memory past the cap",
format!(
r#"(module (memory {})
(func (export "finish") (result i32) (i32.const 0)))"#,
MAX_MEMORY_PAGES + 1
),
),
] {
let wasm = assemble(&wat);
passes(&wat);
let failure = xrpl_wasm_vm::run(&wasm, PLENTY_OF_GAS, &host, ENTRY)
.expect_err(&format!("{label}: expected the run to refuse it"));
assert!(
matches!(failure.error, RunError::Instantiate(_)),
"{label}: {failure}"
);
}
}

View File

@@ -109,6 +109,33 @@ flag. `the_vm_refuses_a_text_format_module` catches that coming back, and
section scan would do it. It is metered and memory-capped regardless, since `run` installs
the fuel and the limiter before `instantiate_and_start`.
## Screening without running: `check`
`preflight.rs`'s `check` decides whether `run` would refuse a module before the guest's first
instruction — compile, imports, entry point — from **the compiled module alone**: no host, no
store, no gas, no execution. That is not economy, it is a requirement; the caller is a
transaction's preflight, which has no ledger to serve a host call from.
Three things keep it from becoming a second opinion. Both stages compile through `vm::compile`,
so the configuration that decides validity cannot differ. The import set is
`HostFunctionSpec::ALL`, which is also what `register_host_functions` iterates, so adding a
host function extends the check and the linker at once. And the entry point's three faults are
described by one `entry_point_fault`, called from `run` with wasmi's error appended.
What it cannot see is guest behaviour and anything absent from the module's exports: a start
section that traps, and a linear memory over the page cap that the module keeps to itself.
Both pass the check and then fail instantiation, which is why a run's own refusal at that
stage cannot be read as the node's fault. `what_static_screening_cannot_see` lists them and
`screening_and_a_run_agree` pins the equivalence everywhere else — in both directions, so a
rule that refused a contract the engine would have served fails too.
Import **signatures** are the deliberate gap: `check` compares names and kinds, not types, so
a mistyped import still parts a module from the engine at instantiation. Closing it needs the
expected `FuncType` per function, and the only non-duplicating source is the closure
`register.rs` registers — `wasmi::IntoFunc::into_func()` returns `(FuncType, _)` and
`Linker::func_wrap` is a thin wrapper over it, so making `register_host_functions` generic
over a sink would give the linker and a type table from one declaration site.
**A dead end, recorded so nobody retries it.** Host-function parameters cannot be newtypes.
`wasmi::WasmTy` looks implementable — public, no sealing supertrait — but its bound names
`UntypedVal`, which wasmi re-exports only through a **private** `mod core`

View File

@@ -6,7 +6,7 @@ bridge that connects it to xrpld.
| Read | When |
|---|---|
| [bridge.md](bridge.md) | changing anything that crosses between C++ and Rust, or the TER map |
| [engine.md](engine.md) | changing `vm.rs`, `abi.rs`, `region.rs` — the invariants, and the wasmi facts that decided them |
| [engine.md](engine.md) | changing `vm.rs`, `preflight.rs`, `abi.rs`, `region.rs` — the invariants, and the wasmi facts that decided them |
| [abi.md](abi.md) | adding or changing a host function |
| [testing.md](testing.md) | running the loop, or adding a test on either side |
| [conventions.md](conventions.md) | before writing code or comments in the crate |
@@ -45,7 +45,8 @@ than guessing. See [history.md](history.md) for what is worth recovering.
Also `HostError`. **The single source of truth for the ABI** — see [abi.md](abi.md).
- `xrpl-host-functions-macros/` — the proc macro. An implementation detail of the crate
above, deliberately not re-exported: the ABI has one declaration site.
- `xrpl-wasm-vm/` — the wasmi wrapper. `vm.rs` (engine, store, `run`), `abi.rs` (gas,
- `xrpl-wasm-vm/` — the wasmi wrapper. `vm.rs` (engine, store, `run`), `preflight.rs`
(`check` — compile, imports, entry point, with no host, store or gas), `abi.rs` (gas,
transfer budget, guest-memory marshaling), `region.rs` (the `(ptr, len)` type),
`register.rs` (one `func_wrap` per host function). See [engine.md](engine.md).
- `xrpl-wasm-vm-ffi/` — the cxx bridge, both crossings. `RunStatus`/`RunResult`,
@@ -67,11 +68,11 @@ than guessing. See [history.md](history.md) for what is worth recovering.
## Current state (2026-08-04)
**The whole workspace is green**: `cargo test --workspace`, `clippy --workspace
--all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`. **137 tests** — 33 macro,
12 facade, 1 doctest, **79 in `xrpl-wasm-vm`** (10 unit; 69 integration — 13 `budgets`,
12 `host_calls`, 23 `memory_policy`, 21 `vm_limits`), 10 in `xrpl-wasm-vm-ffi`, 2 in
`xrpl-wasm-testkit`. On the C++ side, **27 tests over the whole loop** in six fixtures:
`./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'`.
--all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`. **154 tests** — 33 macro,
12 facade, 1 doctest, **96 in `xrpl-wasm-vm`** (11 unit; 85 integration — 13 `budgets`,
12 `host_calls`, 23 `memory_policy`, 16 `preflight`, 21 `vm_limits`), 10 in
`xrpl-wasm-vm-ffi`, 2 in `xrpl-wasm-testkit`. On the C++ side, **27 tests over the whole
loop** in six fixtures: `./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'`.
**Both crossings are wired and a real contract runs through them**: C++ calls
`runEscrowWasm`, the engine services `ldgr_index` by calling back into
@@ -84,11 +85,15 @@ functions are registered (`ldgr_index`, `home_le_field`, `sha512_half`, `trace`,
## Next
1. **`preflightEscrowWasm`.** The gap the TER map is currently papering over: a module that
will not compile, instantiate, or expose the entry point maps to `tecINTERNAL` with no
cost, which is only defensible because preflight is *meant* to have refused it with
`temBAD_WASM` first. Nothing does that yet. It needs a second bridge entry that compiles
and looks up the export without executing.
1. **`preflightEscrowWasm`.** The engine half is done — `check` in `preflight.rs`. What is
left is the second bridge entry (`check_escrow`, a `CheckStatus`/`CheckResult` pair
mirroring `RunStatus`/`RunResult`) and the C++ front, whose signature is
`(Bytes, beast::Journal, std::string_view) -> NotTEC`: **no `HostFunctions&`**, since a
check needs no host and a `PreflightContext` has no ledger to build one from.
Two decisions are still open — what a *panic* at preflight returns
(`telFAILED_PROCESSING` reads as the preflight analogue of `tecINTERNAL`'s "the fault is
the node's"), and whether the apply-side map moves `Instantiate` off `tecINTERNAL` in the
same change; see [bridge.md](bridge.md).
2. **A caller.** `EscrowFinish.cpp` still has no wasm reference, so `runEscrowWasm` is
reached only from `src/tests/libxrpl/tx/wasm/`. Wiring it up is what makes
`WasmHostFunctionsImpl` (over a real `ApplyContext`) the host in production rather than