From 8ecb77dcfb026761293a04c634e50883ce94d0d4 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Tue, 4 Aug 2026 13:53:25 +0100 Subject: [PATCH] Add check to vm --- crates/xrpl-wasm-vm/src/lib.rs | 2 + crates/xrpl-wasm-vm/src/preflight.rs | 134 +++++++++ crates/xrpl-wasm-vm/src/register.rs | 2 +- crates/xrpl-wasm-vm/src/vm.rs | 36 +-- crates/xrpl-wasm-vm/tests/preflight.rs | 401 +++++++++++++++++++++++++ docs/claude/wasm-vm/engine.md | 27 ++ docs/claude/wasm-vm/index.md | 29 +- 7 files changed, 600 insertions(+), 31 deletions(-) create mode 100644 crates/xrpl-wasm-vm/src/preflight.rs create mode 100644 crates/xrpl-wasm-vm/tests/preflight.rs diff --git a/crates/xrpl-wasm-vm/src/lib.rs b/crates/xrpl-wasm-vm/src/lib.rs index 933ee58628..aae00006c8 100644 --- a/crates/xrpl-wasm-vm/src/lib.rs +++ b/crates/xrpl-wasm-vm/src/lib.rs @@ -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, diff --git a/crates/xrpl-wasm-vm/src/preflight.rs b/crates/xrpl-wasm-vm/src/preflight.rs new file mode 100644 index 0000000000..8fbc49829e --- /dev/null +++ b/crates/xrpl-wasm-vm/src/preflight.rs @@ -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, 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:?}"); + } + } +} diff --git a/crates/xrpl-wasm-vm/src/register.rs b/crates/xrpl-wasm-vm/src/register.rs index db0397f955..ab87571cfe 100644 --- a/crates/xrpl-wasm-vm/src/register.rs +++ b/crates/xrpl-wasm-vm/src/register.rs @@ -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. /// diff --git a/crates/xrpl-wasm-vm/src/vm.rs b/crates/xrpl-wasm-vm/src/vm.rs index d50f5b7bd7..1b07a5c108 100644 --- a/crates/xrpl-wasm-vm/src/vm.rs +++ b/crates/xrpl-wasm-vm/src/vm.rs @@ -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::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 { 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, 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::*; diff --git a/crates/xrpl-wasm-vm/tests/preflight.rs b/crates/xrpl-wasm-vm/tests/preflight.rs new file mode 100644 index 0000000000..e6e67ed3b5 --- /dev/null +++ b/crates/xrpl-wasm-vm/tests/preflight.rs @@ -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}" + ); + } +} diff --git a/docs/claude/wasm-vm/engine.md b/docs/claude/wasm-vm/engine.md index adf5f581cb..3921389385 100644 --- a/docs/claude/wasm-vm/engine.md +++ b/docs/claude/wasm-vm/engine.md @@ -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` diff --git a/docs/claude/wasm-vm/index.md b/docs/claude/wasm-vm/index.md index c0091b1abc..9cd735db84 100644 --- a/docs/claude/wasm-vm/index.md +++ b/docs/claude/wasm-vm/index.md @@ -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