Add preflight to c++ code

This commit is contained in:
Sergey Kuznetsov
2026-08-04 14:55:56 +01:00
parent 8ecb77dcfb
commit 5b2fc952d3
11 changed files with 891 additions and 136 deletions

View File

@@ -1,11 +1,14 @@
//! The cxx bridge between the escrow wasm engine and xrpld.
//!
//! Two crossings. C++ calls `run_escrow` once per escrow finish; the engine's host
//! Three crossings. C++ calls `run_escrow` once per escrow finish; the engine's host
//! calls come back out through the C++ `HostContext`, which `CxxHost` presents to the
//! engine as an ordinary [`HostFunctions`] implementor. The ABI those calls speak is
//! declared once, in `xrpl-host-functions`, so neither side of this file gets to
//! restate a signature.
//!
//! `check_escrow` is the third, and it crosses in one direction only: screening a
//! module needs no host, so nothing comes back out.
//!
//! **Neither direction may unwind into the other**, and the two halves of that are
//! not symmetric:
//!
@@ -26,7 +29,7 @@
use std::any::Any;
use std::panic::{AssertUnwindSafe, catch_unwind};
use xrpl_host_functions::{HostError, HostFunctions, HostResult};
use xrpl_wasm_vm::{RunError, RunFailure, RunOutcome, run};
use xrpl_wasm_vm::{CheckError, RunError, RunFailure, RunOutcome, check, run};
/// [`guarded`] must be able to stop an unwind. Under `panic = "abort"` it cannot,
/// and every arithmetic overflow in the engine becomes a node crash instead of a
@@ -77,6 +80,35 @@ mod ffi {
detail: String,
}
/// Why a module cannot be run — one variant per way [`check`] can refuse it,
/// so the caller maps a status to a TER rather than reading a message.
#[derive(Debug, Hash)]
#[repr(i32)]
enum CheckStatus {
/// The module compiles, imports only what the engine serves, and exports
/// the entry point as `() -> i32`.
Ok,
/// `wasm` is not a valid module under this engine's configuration.
Compile,
/// An import the engine does not define: another module namespace, a name
/// that is not a host function, or one imported as something else.
Import,
/// No export of that name with signature `() -> i32`.
EntryPoint,
/// The engine panicked. A defect in this crate or the one below it, and
/// not a fault in the module — which is why it is a status of its own
/// rather than one more way a contract can be malformed.
Panic,
}
/// A check's verdict. No cost, because nothing was executed.
struct CheckResult {
status: CheckStatus,
/// The engine's own description of the refusal, for the log. Empty on
/// `Ok`.
detail: String,
}
extern "Rust" {
/// Run `wasm`'s `function_name` export with `gas` fuel, servicing host calls
/// through `host`.
@@ -89,6 +121,15 @@ mod ffi {
/// instruction; the C++ front refuses it as `temBAD_AMOUNT` before calling
/// here, so it is not given a status of its own.
fn run_escrow(host: &HostContext, wasm: &[u8], gas: u64, function_name: &str) -> RunResult;
/// Screen `wasm` before it can reach the ledger: whether [`run_escrow`]
/// would refuse it before the guest's first instruction.
///
/// Takes no host, no gas and no store — the verdict comes from the
/// compiled module alone, which is what makes it callable from a
/// transaction's preflight, where there is no ledger to serve a host call
/// from. **Never throws**, for the same reason [`run_escrow`] does not.
fn check_escrow(wasm: &[u8], function_name: &str) -> CheckResult;
}
unsafe extern "C++" {
@@ -147,6 +188,11 @@ struct CxxHost<'a> {
///
/// The conversion *is* the sign test — it fails on exactly the negative values — so
/// there is no cast to argue about.
///
/// Named functions rather than `From` impls, and not by preference: every type
/// involved — `i32`, `Result`, `HostError` — is foreign to this crate, so the orphan
/// rule forbids the impl. Two readings of the same `i32` would want distinguishing
/// names here in any case.
fn bytes_written(n: i32) -> HostResult<usize> {
usize::try_from(n).map_err(|_| HostError::from_code(n))
}
@@ -187,14 +233,50 @@ fn run_escrow(
gas: u64,
function_name: &str,
) -> ffi::RunResult {
guarded(|| {
let host = CxxHost { ctx: host };
flatten(run(wasm, gas, &host, function_name))
})
guarded(
|| {
let host = CxxHost { ctx: host };
run(wasm, gas, &host, function_name).into()
},
ffi::RunResult::panicked,
)
}
/// Run `body`, turning a panic into [`ffi::RunStatus::Panic`] rather than letting it
/// unwind into C++.
fn check_escrow(wasm: &[u8], function_name: &str) -> ffi::CheckResult {
guarded(
|| check(wasm, function_name).into(),
ffi::CheckResult::panicked,
)
}
impl ffi::RunResult {
/// A run the engine panicked in.
///
/// The cost is not reported: a panicking run's meter is not evidence of
/// anything, and `0` says "unknown" where a number would say "this is what it
/// owed".
fn panicked(detail: String) -> ffi::RunResult {
ffi::RunResult {
status: ffi::RunStatus::Panic,
result: 0,
gas_used: 0,
detail,
}
}
}
impl ffi::CheckResult {
/// A check the engine panicked in.
fn panicked(detail: String) -> ffi::CheckResult {
ffi::CheckResult {
status: ffi::CheckStatus::Panic,
detail,
}
}
}
/// Run `body`, handing a panic to `panicked` rather than letting it unwind into
/// C++.
///
/// **Why catching here is enough.** An unwind can only be caught where every frame
/// between the panic and the catch is Rust, and every frame here is: the engine and
@@ -207,15 +289,11 @@ fn run_escrow(
/// and the one thing that outlives the call — the C++ `HostContext` — is only ever
/// touched through those `noexcept` methods, which either complete or report.
///
/// The cost is not reported. A panicking run's meter is not evidence of anything, and
/// `0` says "unknown" where a number would say "this is what it owed".
fn guarded(body: impl FnOnce() -> ffi::RunResult) -> ffi::RunResult {
catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| ffi::RunResult {
status: ffi::RunStatus::Panic,
result: 0,
gas_used: 0,
detail: panic_detail(&*payload),
})
/// Generic over the result so both crossings share the one catch: the two answer
/// with different structs, and a second `catch_unwind` is the last thing this file
/// should have two of.
fn guarded<T>(body: impl FnOnce() -> T, panicked: impl FnOnce(String) -> T) -> T {
catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| panicked(panic_detail(&*payload)))
}
/// The panic's message, for the log.
@@ -231,23 +309,29 @@ fn panic_detail(payload: &(dyn Any + Send)) -> String {
format!("panicked: {message}")
}
/// Flatten the engine's two-channel result onto the one struct cxx can carry.
fn flatten(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
match result {
Ok(RunOutcome { result, fuel_used }) => ffi::RunResult {
status: ffi::RunStatus::Ok,
result,
gas_used: fuel_used,
detail: String::new(),
},
// `fuel_used` is carried on both channels by construction, so a failed run
// reports its cost here without this having to decide what one is.
Err(RunFailure { error, fuel_used }) => ffi::RunResult {
status: status_of(&error),
result: 0,
gas_used: fuel_used,
detail: error.to_string(),
},
/// The engine's two-channel result on the one struct cxx can carry.
///
/// A `From` rather than a named function because the mapping is total and there is
/// only one of it: every field of the wire struct is decided by the outcome, so
/// there is no second reading for a name to distinguish.
impl From<Result<RunOutcome, RunFailure>> for ffi::RunResult {
fn from(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
match result {
Ok(RunOutcome { result, fuel_used }) => ffi::RunResult {
status: ffi::RunStatus::Ok,
result,
gas_used: fuel_used,
detail: String::new(),
},
// `fuel_used` is carried on both channels by construction, so a failed
// run reports its cost here without this having to decide what one is.
Err(RunFailure { error, fuel_used }) => ffi::RunResult {
status: ffi::RunStatus::from(&error),
result: 0,
gas_used: fuel_used,
detail: error.to_string(),
},
}
}
}
@@ -255,15 +339,45 @@ fn flatten(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
///
/// Exhaustive rather than closed with a wildcard: an outcome added to the engine has
/// to be given a status — and therefore a TER on the far side — before this compiles.
fn status_of(error: &RunError) -> ffi::RunStatus {
match error {
RunError::Compile(_) => ffi::RunStatus::Compile,
RunError::Instantiate(_) => ffi::RunStatus::Instantiate,
RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint,
RunError::OutOfGas => ffi::RunStatus::OutOfGas,
RunError::Internal => ffi::RunStatus::Internal,
RunError::NoMemory => ffi::RunStatus::NoMemory,
RunError::Trap(_) => ffi::RunStatus::Trap,
impl From<&RunError> for ffi::RunStatus {
fn from(error: &RunError) -> ffi::RunStatus {
match error {
RunError::Compile(_) => ffi::RunStatus::Compile,
RunError::Instantiate(_) => ffi::RunStatus::Instantiate,
RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint,
RunError::OutOfGas => ffi::RunStatus::OutOfGas,
RunError::Internal => ffi::RunStatus::Internal,
RunError::NoMemory => ffi::RunStatus::NoMemory,
RunError::Trap(_) => ffi::RunStatus::Trap,
}
}
}
/// A verdict on the wire. No cost to carry, so `Ok` is the empty description.
impl From<Result<(), CheckError>> for ffi::CheckResult {
fn from(result: Result<(), CheckError>) -> ffi::CheckResult {
match result {
Ok(()) => ffi::CheckResult {
status: ffi::CheckStatus::Ok,
detail: String::new(),
},
Err(error) => ffi::CheckResult {
status: ffi::CheckStatus::from(&error),
detail: error.to_string(),
},
}
}
}
/// The status a [`CheckError`] crosses as, exhaustive for the same reason
/// [`ffi::RunStatus`]'s conversion is.
impl From<&CheckError> for ffi::CheckStatus {
fn from(error: &CheckError) -> ffi::CheckStatus {
match error {
CheckError::Compile(_) => ffi::CheckStatus::Compile,
CheckError::Import(_) => ffi::CheckStatus::Import,
CheckError::EntryPoint(_) => ffi::CheckStatus::EntryPoint,
}
}
}
@@ -275,11 +389,13 @@ mod tests {
use super::*;
fn ok(result: i32, fuel_used: u64) -> ffi::RunResult {
flatten(Ok(RunOutcome { result, fuel_used }))
let outcome: Result<RunOutcome, RunFailure> = Ok(RunOutcome { result, fuel_used });
outcome.into()
}
fn failed(error: RunError, fuel_used: u64) -> ffi::RunResult {
flatten(Err(RunFailure { error, fuel_used }))
let outcome: Result<RunOutcome, RunFailure> = Err(RunFailure { error, fuel_used });
outcome.into()
}
#[test]
@@ -303,8 +419,8 @@ mod tests {
assert_eq!(crossed.result, 0, "a failed run returned no value");
}
/// The `RunError` set as the test *expects* it, not as `status_of` reports it:
/// deriving it from the function under test would make the assertion vacuous.
/// The `RunError` set as the test *expects* it, not as the conversion reports it:
/// deriving it from the code under test would make the assertion vacuous.
fn every_run_error() -> Vec<RunError> {
vec![
RunError::Compile(String::new()),
@@ -323,7 +439,7 @@ mod tests {
fn every_run_error_crosses_as_a_status_of_its_own() {
let mut seen = Vec::new();
for error in every_run_error() {
let status = status_of(&error);
let status = ffi::RunStatus::from(&error);
assert!(
!seen.contains(&status),
"{error:?} shares {status:?} with an earlier outcome"
@@ -337,13 +453,17 @@ mod tests {
#[test]
fn no_failure_crosses_as_success() {
for error in every_run_error() {
assert_ne!(status_of(&error), ffi::RunStatus::Ok, "{error:?}");
assert_ne!(
ffi::RunStatus::from(&error),
ffi::RunStatus::Ok,
"{error:?}"
);
}
}
#[test]
fn a_panic_becomes_a_status_instead_of_an_unwind() {
let crossed = guarded(|| panic!("the engine came apart"));
let crossed = guarded(|| panic!("the engine came apart"), ffi::RunResult::panicked);
assert_eq!(crossed.status, ffi::RunStatus::Panic);
assert_eq!(crossed.detail, "panicked: the engine came apart");
@@ -355,14 +475,17 @@ mod tests {
#[test]
fn a_formatted_panic_keeps_its_message() {
let overflowed = 3;
let crossed = guarded(|| panic!("gas underflowed by {overflowed}"));
let crossed = guarded(
|| panic!("gas underflowed by {overflowed}"),
ffi::RunResult::panicked,
);
assert_eq!(crossed.detail, "panicked: gas underflowed by 3");
}
#[test]
fn a_panic_with_no_message_still_reports_one() {
let crossed = guarded(|| std::panic::panic_any(7u32));
let crossed = guarded(|| std::panic::panic_any(7u32), ffi::RunResult::panicked);
assert_eq!(crossed.status, ffi::RunStatus::Panic);
assert_eq!(crossed.detail, "panicked: payload is not a string");
@@ -370,7 +493,7 @@ mod tests {
#[test]
fn a_run_that_does_not_panic_is_untouched() {
let crossed = guarded(|| ok(1, 2));
let crossed = guarded(|| ok(1, 2), ffi::RunResult::panicked);
assert_eq!(crossed.status, ffi::RunStatus::Ok);
assert_eq!(crossed.result, 1);
@@ -394,4 +517,88 @@ mod tests {
assert_eq!(bytes_written(-1), Err(HostError::Internal));
assert_eq!(reported(-1), Err(HostError::Internal));
}
// -----------------------------------------------------------------------
// The check crossing
//
// `check_escrow` takes no host, so unlike `run_escrow` it can be called
// outright here — the modules are hand-written bytes because this crate has
// no assembler and needs none for two of them.
// -----------------------------------------------------------------------
/// The smallest valid module: the eight-byte header and nothing else. It
/// compiles and imports nothing, so it reaches the entry-point stage.
const EMPTY_MODULE: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
#[test]
fn a_module_that_does_not_compile_crosses_as_compile() {
let crossed = check_escrow(b"not wasm", "escrow_finish");
assert_eq!(crossed.status, ffi::CheckStatus::Compile);
assert!(
crossed.detail.starts_with("compile: "),
"{}",
crossed.detail
);
}
/// The whole crossing, end to end: a real module through the real engine, with
/// the refusal the C++ side will log.
#[test]
fn a_module_without_the_entry_point_crosses_as_entry_point() {
let crossed = check_escrow(&EMPTY_MODULE, "escrow_finish");
assert_eq!(crossed.status, ffi::CheckStatus::EntryPoint);
assert_eq!(crossed.detail, "no entry point 'escrow_finish'");
}
/// The `CheckError` set as the test *expects* it, not as the conversion reports
/// it: deriving it from the code under test would make the assertion vacuous.
fn every_check_error() -> Vec<CheckError> {
vec![
CheckError::Compile(String::new()),
CheckError::Import(String::new()),
CheckError::EntryPoint(String::new()),
]
}
/// Distinct statuses, because the TER map on the far side reads nothing else.
#[test]
fn every_check_error_crosses_as_a_status_of_its_own() {
let mut seen = Vec::new();
for error in every_check_error() {
let status = ffi::CheckStatus::from(&error);
assert!(
!seen.contains(&status),
"{error:?} shares {status:?} with an earlier refusal"
);
seen.push(status);
}
}
/// `Ok` is the one status no refusal may take: the far side reads it as
/// `tesSUCCESS` and would let the module through.
#[test]
fn no_refusal_crosses_as_success() {
for error in every_check_error() {
assert_ne!(
ffi::CheckStatus::from(&error),
ffi::CheckStatus::Ok,
"{error:?}"
);
}
}
/// A panic during a check is its own status rather than one more malformed
/// module: the far side answers a node-local failure, not `temBAD_WASM`.
#[test]
fn a_panic_during_a_check_becomes_a_status_instead_of_an_unwind() {
let crossed = guarded(
|| panic!("the checker came apart"),
ffi::CheckResult::panicked,
);
assert_eq!(crossed.status, ffi::CheckStatus::Panic);
assert_eq!(crossed.detail, "panicked: the checker came apart");
}
}

View File

@@ -54,34 +54,39 @@ pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
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.
/// Every import must be one the linker defines. The first that is not ends the
/// check, so a module with several faults reports the earliest.
fn check_imports(module: &Module) -> Result<(), CheckError> {
for import in module.imports() {
let name = import.name();
check_import(import.module(), import.name(), import.ty()).map_err(CheckError::Import)?;
}
Ok(())
}
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"
)));
}
/// Whether the engine defines this one import.
///
/// The set of names 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 `ty`'s *signature*, which
/// still parts a module from the engine at instantiation; the kind is compared
/// because the engine defines these names as functions and as nothing else.
///
/// The rules are ordered, not merely alternatives: a guest importing `env::malloc`
/// is told about the namespace rather than that `malloc` is not a host function,
/// because the namespace is the one that explains every other import it has too.
fn check_import(module: &str, name: &str, ty: &ExternType) -> Result<(), String> {
if module != HOST_MODULE {
return Err(format!("'{module}::{name}' is not from '{HOST_MODULE}'"));
}
if !HostFunctionSpec::ALL
.iter()
.any(|op| op.wasm_name() == name)
{
return Err(format!("no host function '{name}'"));
}
if !matches!(ty, ExternType::Func(_)) {
return Err(format!("'{HOST_MODULE}::{name}' is not a function"));
}
Ok(())
}
@@ -112,9 +117,97 @@ pub(crate) fn entry_point_fault(found: Option<ExternType>, name: &str) -> String
}
}
/// The rules, one by one, on inputs built directly rather than parsed out of a
/// module. `tests/preflight.rs` runs real modules through [`check`]; what is here is
/// what a module cannot state precisely — which rule fires, in which order, and in
/// what words the caller logs it.
///
/// `wat` is a dev-dependency, so the one test here that does need a module writes it
/// as text like every other test in the crate. What the library must not gain is a
/// text *entry point* — `check` and `run` take binaries — and a `cfg(test)` caller
/// cannot give it one.
#[cfg(test)]
mod tests {
use super::*;
use wasmi::{GlobalType, MemoryType, Mutability};
/// A host function as a guest declares it. Any function type will do: the
/// signature is not what [`check_import`] compares.
fn a_function() -> ExternType {
ExternType::Func(FuncType::new([ValType::I32], [ValType::I32]))
}
/// A name every one of these tests can use, taken from the ABI rather than
/// spelled, so it stays a real host function as the ABI changes.
fn a_host_function_name() -> &'static str {
HostFunctionSpec::ALL[0].wasm_name()
}
// -----------------------------------------------------------------------
// Imports
// -----------------------------------------------------------------------
/// Every name the ABI declares is served. Derived from `ALL` rather than
/// listed, so a host function added to the ABI is covered the day it lands.
#[test]
fn every_declared_host_function_is_served() {
for op in HostFunctionSpec::ALL {
assert_eq!(
check_import(HOST_MODULE, op.wasm_name(), &a_function()),
Ok(()),
"{}",
op.wasm_name()
);
}
}
#[test]
fn an_import_from_another_namespace_is_refused() {
for namespace in ["env", "host", "host_lib2", ""] {
let refusal = check_import(namespace, a_host_function_name(), &a_function())
.expect_err(namespace);
assert!(
refusal.contains("is not from 'host_lib'"),
"{namespace}: {refusal}"
);
}
}
#[test]
fn an_unknown_name_is_refused() {
let refusal =
check_import(HOST_MODULE, "no_such_function", &a_function()).expect_err("unknown name");
assert_eq!(refusal, "no host function 'no_such_function'");
}
/// The engine defines these names as functions and as nothing else, so a module
/// importing one as a global or a memory does not link either.
#[test]
fn a_host_function_imported_as_anything_else_is_refused() {
for ty in [
ExternType::Global(GlobalType::new(ValType::I32, Mutability::Const)),
ExternType::Memory(MemoryType::new(1, None)),
] {
let name = a_host_function_name();
let refusal = check_import(HOST_MODULE, name, &ty).expect_err("not a function");
assert_eq!(refusal, format!("'host_lib::{name}' is not a function"));
}
}
/// The rules are ordered. An import that breaks two of them is reported by the
/// first, so the message a contract author reads is the one that explains the
/// rest of their imports too.
#[test]
fn the_namespace_is_reported_before_the_name() {
let refusal = check_import("env", "no_such_function", &a_function())
.expect_err("neither the namespace nor the name is served");
assert!(refusal.contains("is not from 'host_lib'"), "{refusal}");
assert!(
!refusal.contains("no host function"),
"the namespace explains it: {refusal}"
);
}
/// Both halves of the type are load-bearing, and neither is checked anywhere
/// a module cannot reach.
@@ -131,4 +224,64 @@ mod tests {
assert!(!is_entry_point(&wrong), "{wrong:?}");
}
}
/// Three faults, three descriptions. A run reports these too, with wasmi's own
/// error appended, so a swapped arm would mislead at both stages at once.
#[test]
fn each_entry_point_fault_is_described_as_itself() {
assert_eq!(
entry_point_fault(Some(a_function()), "finish"),
"entry point 'finish' has the wrong signature, expected '() -> i32'"
);
assert_eq!(
entry_point_fault(
Some(ExternType::Global(GlobalType::new(
ValType::I32,
Mutability::Const
))),
"finish"
),
"export 'finish' is not a function"
);
assert_eq!(
entry_point_fault(None, "finish"),
"no entry point 'finish'",
"an absent export must not be reported as a wrong signature"
);
}
/// The bridge logs this string and the C++ tests match on it, so the stage's
/// prefix is part of the interface rather than a debugging aid.
#[test]
fn a_refusal_names_its_stage() {
assert_eq!(
CheckError::Compile("bad magic".to_string()).to_string(),
"compile: bad magic"
);
assert_eq!(
CheckError::Import("no host function 'x'".to_string()).to_string(),
"import: no host function 'x'"
);
// The entry point's detail already says which of its three faults it is,
// so a prefix would only repeat it.
assert_eq!(
CheckError::EntryPoint("no entry point 'finish'".to_string()).to_string(),
"no entry point 'finish'"
);
}
#[test]
fn the_stages_run_in_order() {
assert!(
matches!(check(b"not wasm", "finish"), Err(CheckError::Compile(_))),
"nothing is screened until the module compiles"
);
// A module that compiles and imports nothing, so it reaches the entry point.
let empty = wat::parse_str("(module)").expect("assembles");
assert!(
matches!(check(&empty, "finish"), Err(CheckError::EntryPoint(_))),
"a module that compiles and imports nothing reaches the entry point"
);
}
}

View File

@@ -174,6 +174,21 @@ fn a_host_function_imported_as_a_global_does_not_pass() {
);
}
/// A module faulty at two stages is refused by the earlier one — it imports what no
/// engine serves *and* exports no entry point. The imports are what the rest of the
/// module depends on, so that is the message worth having.
#[test]
fn the_earlier_stage_is_the_one_reported() {
let refusal = refusal(
r#"(module
(import "host_lib" "no_such_function" (func $f (result i32)))
(memory (export "memory") 1)
(func (export "not_the_entry_point") (result i32) (call $f)))"#,
);
assert_stage!(refusal, CheckError::Import(_));
}
/// 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.

View File

@@ -5,6 +5,11 @@
`crates/xrpl-wasm-vm-ffi/src/lib.rs` is the whole of the Rust half; `HostContext.{h,cpp}` and
`WasmVM.{h,cpp}` are the C++ half. Two decisions carry the design.
Three crossings, not two: `run_escrow` in, the host calls back out, and `check_escrow`
in. The third goes one way only — screening a module needs no host — so it takes no
`HostContext`, has no C++-exception half to contain, and is the one bridge function the
crate's own tests can call outright.
**The result is total, not `Result<T>`.** cxx's `Result` sugar throws a `rust::Error` into
C++; a status is the better interface for a condition the caller has to turn into a TER
anyway. `RunResult { status, result, gas_used, detail }` flattens the engine's
@@ -26,6 +31,12 @@ given a status *and* a TER.
- The asymmetry is what makes each half sufficient: because the C++ shims never unwind,
every frame between a panic and `catch_unwind` is Rust.
Both halves are named `guarded`, and each is one function that every crossing goes through:
Rust's takes the panic arm as an argument (`ffi::RunResult::panicked`), C++'s takes the value
to answer with if the call throws. Anything C++ catches there is xrpld's own — a bad
allocation, or a `funcName` that is not valid UTF-8 and so cannot become a `rust::Str`
never a wasm outcome, since those arrive as statuses.
`HostContext` holds a `HostFunctions&` and lowers its typed `std::expected` onto the wire.
The `&self`-vs-non-const worry was a non-issue: a `const` member function holding a
non-const reference can still call `cacheLedgerObj`/`updateData`. `cxx_name` on each method
@@ -53,13 +64,55 @@ node's, and charging a transaction for a node's defect would write that defect i
| `Internal`, `Panic` | `tecINTERNAL` | none |
The `Compile`/`Instantiate`/`EntryPoint` row is `tecINTERNAL` because preflight is meant to
have refused such a module with `temBAD_WASM` long before apply — which is why preflight is
item 1 in [the roadmap](index.md#next). `NoMemory` had no old TER to match (it used to reach
the guest as code -14); `tecFAILED_PROCESSING` treats it as the contract fault it is.
have refused such a module with `temBAD_WASM` long before apply. **That row is now known to
be wrong for `Instantiate`** — see below. `NoMemory` had no old TER to match (it used to
reach the guest as code -14); `tecFAILED_PROCESSING` treats it as the contract fault it is.
`gas <= 0` is refused as `temBAD_AMOUNT` before the engine is called, restoring what
`WasmiEngine::run` did — see [open-questions.md](open-questions.md).
## The preflight map
`preflightEscrowWasm` owns it, and it is deliberately flat: every fault in the module is
one answer, because a caller's only decision is whether the transaction may proceed.
| `CheckStatus` | `NotTEC` |
|---|---|
| `Ok` | `tesSUCCESS` |
| `Compile`, `Import`, `EntryPoint` | `temBAD_WASM` |
| `Panic` | `telFAILED_PROCESSING` |
The statuses stay distinct anyway: the *detail* is what a contract author needs, and one
status per stage keeps the map's arms reviewable and lets it grow without inventing
distinctions later.
`Panic` is not `temBAD_WASM`. A defect in the engine teaches nothing about the module, and
`tem` would record our bug as the transaction's malformation; `tel` is the preflight
analogue of `tecINTERNAL`'s "the fault is the node's" — local, not forwarded, no fee. Two
things follow that are worth stating: divergence between nodes is not what the code choice
fixes (a panic in deterministic code is not node-local, and if it were, no TER would
reconcile the two), and this arm has no test on the C++ side, because there is no reliable
way to make the engine panic from a fixture.
**The signature the C++ front does not have is the point**: `(Bytes, beast::Journal,
std::string_view) -> NotTEC`, with no `HostFunctions&`. The deleted `preflightEscrowWasm`
took one and could therefore never have been called from a real `preflight()`
`PreflightContext` has no view to build a host over.
## Why `Instantiate` should stop being `tecINTERNAL`
`check` closes compile, imports and the entry point, but two ways instantiation fails are
invisible to it: a start section that traps, and a linear memory over the page cap that the
module does not export ([engine.md](engine.md)). Both are deterministic properties of the
module, so a contract can pass preflight, be escrowed, and then fail to instantiate at
apply — where the map currently blames the node and charges nothing.
The fix is two lines and its own change: report `Instantiate` as `tecFAILED_PROCESSING`
with its gas, and in `vm::run` classify a failure carrying a trap code (`e.as_trap_code()`)
as `Trap` rather than `Instantiate`, since a start section trapping is guest code trapping.
`tecINTERNAL` then means what it says — `Internal` and `Panic`, the node's own defects — and
the map stops depending on preflight's completeness for its correctness.
## The one copy left on the byte path, and why it needs `HostFunctions` to change
The engine's side of the byte path is copy-free by construction — `write_into` hands the host

View File

@@ -122,6 +122,13 @@ so the configuration that decides validity cannot differ. The import set is
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.
The rules themselves are pure functions over what a module *declares*`check_import` takes
`(namespace, name, ExternType)`, `entry_point_fault` takes an `Option<ExternType>` — so the
unit tests state each rule, its precedence and its wording on inputs built directly, and
`tests/preflight.rs` is left to run real modules. Precedence is a decision, not an accident:
an import breaking two rules reports the namespace, which is what explains the module's other
imports too.
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

View File

@@ -49,8 +49,9 @@ than guessing. See [history.md](history.md) for what is worth recovering.
(`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`,
`run_escrow`, `CxxHost`, the panic guard. See [bridge.md](bridge.md).
- `xrpl-wasm-vm-ffi/` — the cxx bridge, all three crossings. `RunStatus`/`RunResult` and
`run_escrow`, `CheckStatus`/`CheckResult` and `check_escrow`, `CxxHost`, the panic
guard. See [bridge.md](bridge.md).
- `xrpl-wasm-testkit/`**test-only**: `compile_wat`, so the C++ tests write their modules
as WebAssembly text. A crate of its own so `wat` cannot reach the shipped node; see
[testing.md](testing.md).
@@ -58,7 +59,8 @@ than guessing. See [history.md](history.md) for what is worth recovering.
`HostFunctions` interface), `HostFuncImpl*.cpp` (its implementations, over
`ApplyContext&`), `WasmCommon.h` (`HostFunctionError`, `Wmem`, `WasmTER`, `FieldLocator`).
The bridge's C++ half is `HostContext.{h,cpp}` (the ABI-shaped view of `HostFunctions`)
and `WasmVM.{h,cpp}` (`runEscrowWasm`, gas validation, the TER map).
and `WasmVM.{h,cpp}` (`runEscrowWasm`, `preflightEscrowWasm`, gas validation, both TER
maps).
- `src/tests/libxrpl/tx/wasm/` — the C++ tests, in the `xrpl_tests` gtest binary.
- `include/xrpl/tx/wasm/README.md` is **stale**: it uses the long name `get_ledger_sqn`
where the code registers `ldgr_index`, and references `detail/WasmVM.cpp`,
@@ -68,15 +70,17 @@ 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`. **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.*'`.
--all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`. **168 tests** — 33 macro,
12 facade, 1 doctest, **105 in `xrpl-wasm-vm`** (19 unit; 86 integration — 13 `budgets`,
12 `host_calls`, 23 `memory_policy`, 17 `preflight`, 21 `vm_limits`), 15 in
`xrpl-wasm-vm-ffi`, 2 in `xrpl-wasm-testkit`. On the C++ side, **37 tests over the whole
loop** in seven fixtures: `./xrpl_tests
--gtest_filter='WasmVMTest.*:*Call.*:PreflightTest.*'`.
**Both crossings are wired and a real contract runs through them**: C++ calls
**All three crossings are wired and a real contract runs through them**: C++ calls
`runEscrowWasm`, the engine services `ldgr_index` by calling back into
`xrpl::HostFunctions`, and the guest reads the answer out of its own memory. Five host
`xrpl::HostFunctions`, and the guest reads the answer out of its own memory;
`preflightEscrowWasm` screens a module through the third, with no host at all. Five host
functions are registered (`ldgr_index`, `home_le_field`, `sha512_half`, `trace`,
`trace_num`) out of the ~65 the full ABI will carry.
@@ -85,23 +89,29 @@ functions are registered (`ldgr_index`, `home_le_field`, `sha512_half`, `trace`,
## Next
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
in principle.
3. **A gas parity oracle.** `Wasm_test.cpp` asserts exact gas numbers (e.g. 29'502) and is
1. **Move `Instantiate` off `tecINTERNAL`**, and report a trapping start section as `Trap`
rather than as a module that would not instantiate. Two lines plus their tests, and it is
what actually removes the papering-over: `check` cannot see either of the two remaining
instantiate faults, so the apply-side map must stop depending on preflight's
completeness. [bridge.md](bridge.md) has the reasoning.
2. **A caller.** `EscrowFinish.cpp` still has no wasm reference, so `runEscrowWasm` and
`preflightEscrowWasm` are 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 in principle. **Blocked on the protocol fields**: `FinishFunction` and
`ComputationAllowance` exist nowhere in this fork or upstream, and adding
`FinishFunction` also has to answer the **contract code-size cap** — there is none, and
preflight's cost is linear in the blob (a 249 KB module of duplicate imports measures
1.5 ms, mostly wasmi's own parse).
3. **Import signatures at preflight.** `check` compares an import's namespace, name and
kind, not its type, so a mistyped import still parts a module from the engine at
instantiation. Deferred to when `host_functions!` generates the wasm-level lowering,
which the C header and the typed `link_*` shims in [abi.md](abi.md) both want anyway.
Note the deleted C++ `check` did not compare signatures either, so this is inherited
rather than new.
4. **A gas parity oracle.** `Wasm_test.cpp` asserts exact gas numbers (e.g. 29'502) and is
the best oracle we have, but it is commented out and its fixtures cannot run on this
engine — see the `env` finding in [testing.md](testing.md).
4. **The `Bytes`-by-value copy in `HostFunctions`** — [bridge.md](bridge.md). A
5. **The `Bytes`-by-value copy in `HostFunctions`** — [bridge.md](bridge.md). A
49-signature sweep, so it wants a caller to measure against first.
Also open: the two performance items and the ABI questions in

View File

@@ -39,6 +39,9 @@
(`let f: fn(&ffi::HostContext) -> _ = ...`) fails with `Undefined symbols:
_rs$wasm_vm$cxxbridge1$…`. So keep those tests on pure logic — the status map, the panic
guard, the wire conversions — and put anything that needs a host in the gtest.
**`check_escrow` is the exception**: it takes no `HostContext`, so its tests call the real
bridge function, hand-writing the two modules they need as bytes (the eight-byte header is
a valid module) rather than reaching for an assembler this crate does not have.
## The Rust tests
@@ -99,6 +102,13 @@ Then one fixture per host function — `LedgerSqnCall`, `CurrentLedgerObjFieldCa
`Sha512HalfCall`, `TraceCall`, `TraceNumCall` — because the module *is* that function's shared
setup. `WasmVMTest` keeps what belongs to the engine rather than to any function.
**`PreflightTest` deliberately derives from `testing::Test`, not from `WasmTest`**, and holds
no mock: `preflightEscrowWasm` takes no host, and a fixture that supplied one would hide the
signature that is the point. That is why `assembleWat` is a free function in `WasmFixture.h`
rather than a `WasmTest` member. `PreflightTest.ScreeningAgreesWithARun` is the one test
there that does build a host — it puts the same modules through `runEscrowWasm` so the two
entry points do not have to be trusted to agree.
**The journal is captured, not sent to a null sink.**
`WasmVMTest.ThrowingHostFunctionBecomesInternal` asserts the exception text *and* that the log
names `getLedgerSqn`; without that, an exception silently swallowed with no log would pass, and

View File

@@ -1,5 +1,7 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/tx/wasm/HostFunc.h>
#include <xrpl/tx/wasm/WasmCommon.h>
@@ -30,4 +32,24 @@ runEscrowWasm(
std::int64_t gasLimit,
std::string_view funcName = escrowFunctionName);
// Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's
// first instruction. Compiles the module and reads its imports and exports; runs
// nothing.
//
// Takes no `HostFunctions`, because the verdict comes from the compiled module alone.
// That is what makes this callable from a transactor's `preflight`, which has no view
// to build a host over.
//
// `temBAD_WASM` for every fault in the module - the transaction carries something this
// engine cannot run, so it is refused before it can reach the ledger.
// `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the
// module, and a defect here is not evidence that the transaction is malformed.
//
// Does not throw.
NotTEC
preflightEscrowWasm(
Bytes const& wasmCode,
beast::Journal j,
std::string_view funcName = escrowFunctionName);
} // namespace xrpl

View File

@@ -14,12 +14,14 @@
#include <expected>
#include <optional>
#include <string_view>
#include <type_traits>
namespace xrpl {
namespace {
using RunStatus = rs::wasm_vm::RunStatus;
using CheckStatus = rs::wasm_vm::CheckStatus;
// The engine's outcome as the caller's: a value with its cost, or a TER with the cost to
// record beside it.
@@ -65,9 +67,69 @@ outcome(rs::wasm_vm::RunResult const& run)
case RunStatus::Panic:
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
}
std::unreachable();
}
// Not reachable through the enum, but a value outside it is representable.
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
// Call into the engine, answering `onThrow` if the call throws.
//
// The engine reports every outcome as a status rather than an exception, so anything
// caught here is xrpld's own: a bad allocation, or a `funcName` that is not valid UTF-8
// and so cannot become a `rust::Str`. Both entry points answer such a failure the way
// they answer a defect in the engine itself.
//
// The counterpart of the engine's own `guarded`, which stops a Rust panic on the other
// side of the bridge. Neither side may unwind into the other, and this is this side's
// half: the reason `HostContext`'s methods are `noexcept` rather than relying on cxx is
// documented in `docs/claude/wasm-vm/bridge.md`.
template <class Call>
std::invoke_result_t<Call>
guarded(beast::Journal j, std::invoke_result_t<Call> onThrow, Call&& call)
{
try
{
return call();
}
catch (std::exception const& e)
{
JLOG(j.error()) << "wasm: engine call threw: " << e.what();
}
catch (...)
{
JLOG(j.error()) << "wasm: engine call threw a non-exception";
}
return onThrow;
}
// A screening verdict as a TER.
//
// `temBAD_WASM` says the transaction carries something this engine cannot run: a
// malformed transaction, refused before it can reach the ledger. A panic inside the
// engine is different in kind - nothing was learned about the module - so the answer is
// node-local rather than a claim about the transaction.
//
// Exhaustive over the status enum, with no `default`, for the same reason `outcome` is.
NotTEC
verdict(CheckStatus status)
{
switch (status)
{
case CheckStatus::Ok:
return tesSUCCESS;
// The module will not compile, imports what no engine of this ABI serves, or
// does not export the entry point as `() -> i32`.
case CheckStatus::Compile:
case CheckStatus::Import:
case CheckStatus::EntryPoint:
return temBAD_WASM;
// The engine panicked: a defect in the engine, reported rather than fatal to
// the node, and not the transaction's fault.
case CheckStatus::Panic:
return telFAILED_PROCESSING;
}
std::unreachable();
}
} // namespace
@@ -85,15 +147,16 @@ runEscrowWasm(
if (gasLimit <= 0)
return std::unexpected(WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt});
try
{
// The host caches the current ledger object, the slot table and the contract's
// data for the length of one run, so a reused one would answer a later contract
// out of an earlier contract's state.
auto const nodeSideFault = std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
return guarded(hfs.getJournal(), nodeSideFault, [&]() -> std::expected<EscrowResult, WasmTER> {
// The host caches the current ledger object, the slot table and the
// contract's data for the length of one run, so a reused one would answer a
// later contract out of an earlier contract's state.
if (!hfs.checkSelf())
{
JLOG(hfs.getJournal().error()) << "wasm: host functions not clean before the run";
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
return nodeSideFault;
}
HostContext ctx{hfs};
@@ -111,20 +174,26 @@ runEscrowWasm(
<< ", ter: " << transToken(result.error().ter);
}
return result;
}
// The engine reports every wasm outcome as a status rather than an exception, so
// anything caught here is xrpld's own: a bad allocation, or a `funcName` that is not
// valid UTF-8 and so cannot become a `rust::Str`.
catch (std::exception const& e)
{
JLOG(hfs.getJournal().error()) << "wasm: engine call threw: " << e.what();
}
catch (...)
{
JLOG(hfs.getJournal().error()) << "wasm: engine call threw a non-exception";
}
});
}
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
NotTEC
preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view funcName)
{
return guarded(j, NotTEC{telFAILED_PROCESSING}, [&]() {
auto const checked = rs::wasm_vm::check_escrow(
rust::Slice<std::uint8_t const>(wasmCode.data(), wasmCode.size()),
rust::Str(funcName.data(), funcName.size()));
auto const ter = verdict(checked.status);
if (!isTesSuccess(ter))
{
JLOG(j.warn()) << "wasm: "
<< std::string_view(checked.detail.data(), checked.detail.size())
<< ", ter: " << transToken(ter);
}
return ter;
});
}
} // namespace xrpl

View File

@@ -0,0 +1,199 @@
#include <tx/wasm/WasmFixture.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <xrpl/tx/wasm/WasmVM.h>
#include <gtest/gtest.h>
#include <string>
#include <string_view>
namespace xrpl::test {
namespace {
// A contract the engine can run: it compiles, imports only a declared host function, and
// exports the entry point as `() -> i32`.
constexpr std::string_view kRunnableWat = R"wat(
(module
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32)
(call $ldgr_index (i32.const 0) (i32.const 4))))
)wat";
} // namespace
// `preflightEscrowWasm` takes no host, so this fixture holds none - which is the point of
// the signature, and what deriving from `WasmTest` would hide. Only a journal, to read the
// refusal out of.
class PreflightTest : public testing::Test
{
protected:
CapturingSink sink_;
NotTEC
preflight(std::string_view wat, std::string_view funcName = escrowFunctionName)
{
return preflightEscrowWasm(assembleWat(wat), beast::Journal{sink_}, funcName);
}
NotTEC
preflightBytes(Bytes const& wasm, std::string_view funcName = escrowFunctionName)
{
return preflightEscrowWasm(wasm, beast::Journal{sink_}, funcName);
}
[[nodiscard]] std::string const&
logged() const
{
return sink_.text();
}
};
TEST_F(PreflightTest, RunnableContractPasses)
{
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS);
EXPECT_TRUE(logged().empty()) << logged();
}
TEST_F(PreflightTest, GarbageIsRefused)
{
EXPECT_EQ(preflightBytes(Bytes{}), temBAD_WASM);
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
}
// The engine takes wasm binaries, and text is not one. The suite writes its modules as text
// and assembles them, so this feeds the engine the very text the other tests assemble: a
// transaction's validity must not depend on whether an assembler was linked in.
TEST_F(PreflightTest, TextFormatModuleIsRefused)
{
Bytes const text{kRunnableWat.begin(), kRunnableWat.end()};
EXPECT_EQ(preflightBytes(text), temBAD_WASM);
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS) << "the same module, assembled first";
}
TEST_F(PreflightTest, ImportOfAnUnknownHostFunctionIsRefused)
{
constexpr std::string_view wat = R"wat(
(module
(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32) (call $f (i32.const 0))))
)wat";
EXPECT_EQ(preflight(wat), temBAD_WASM);
EXPECT_THAT(logged(), testing::HasSubstr("no host function 'no_such_function'"));
}
// Host functions are registered under one module name. `env` is what plain clang emits, so a
// contract built without the SDK's import attributes lands here.
TEST_F(PreflightTest, ImportFromAnotherModuleIsRefused)
{
constexpr std::string_view wat = R"wat(
(module
(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32) (i32.const 0)))
)wat";
EXPECT_EQ(preflight(wat), temBAD_WASM);
EXPECT_THAT(logged(), testing::HasSubstr("is not from 'host_lib'"));
}
TEST_F(PreflightTest, MissingEntryPointIsRefused)
{
constexpr std::string_view wat = R"wat(
(module
(memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))
)wat";
EXPECT_EQ(preflight(wat), temBAD_WASM);
EXPECT_THAT(logged(), testing::HasSubstr("no entry point 'escrow_finish'"));
}
TEST_F(PreflightTest, EntryPointOfTheWrongTypeIsRefused)
{
constexpr std::string_view wat = R"wat(
(module
(memory (export "memory") 1)
(func (export "escrow_finish") (result i64) (i64.const 0)))
)wat";
EXPECT_EQ(preflight(wat), temBAD_WASM);
EXPECT_THAT(logged(), testing::HasSubstr("has the wrong signature"));
}
// Screening is for the entry point the caller names, as a run is: a contract screened for one
// export says nothing about another.
TEST_F(PreflightTest, EntryPointIsTheNameTheCallerGives)
{
constexpr std::string_view wat = R"wat(
(module
(memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))
)wat";
EXPECT_EQ(preflight(wat, "other"), tesSUCCESS);
EXPECT_EQ(preflight(wat), temBAD_WASM);
}
// Every refusal is logged with the engine's own description and the TER: without it a node
// operator has a `temBAD_WASM` and no way to tell a contract author which of the three
// stages refused the module.
TEST_F(PreflightTest, RefusalNamesTheReasonAndTheTer)
{
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
EXPECT_THAT(logged(), testing::HasSubstr("compile: "));
EXPECT_THAT(logged(), testing::HasSubstr(transToken(temBAD_WASM)));
}
// A module that passes screening still has to pass the run's own stages, and one that fails
// screening would have failed the run. Same modules through both entry points, so the two do
// not have to be trusted to agree.
TEST_F(PreflightTest, ScreeningAgreesWithARun)
{
struct Case
{
std::string_view label;
std::string_view wat;
bool passes;
};
// clang-format off
constexpr Case cases[]{
{.label = "a runnable contract", .wat = kRunnableWat, .passes = true},
{.label = "an unknown host function",
.wat = R"wat((module (import "host_lib" "nope" (func $f (result i32)))
(memory (export "memory") 1)
(func (export "escrow_finish") (result i32) (call $f))))wat",
.passes = false},
{.label = "no entry point",
.wat = R"wat((module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0))))wat",
.passes = false},
};
// clang-format on
for (auto const& [label, wat, passes] : cases)
{
auto const screened = preflight(wat);
EXPECT_EQ(isTesSuccess(screened), passes) << label;
// The run's own verdict on the same bytes. A refused module must not reach the
// contract's first instruction; an accepted one must get past the entry-point
// lookup, whatever it then does.
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink_}};
EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true));
EXPECT_CALL(host, getLedgerSqn()).WillRepeatedly(testing::Return(7u));
auto const ran = runEscrowWasm(assembleWat(wat), host, 100'000);
EXPECT_EQ(ran.has_value(), passes) << label;
}
}
} // namespace xrpl::test

View File

@@ -49,12 +49,25 @@ public:
}
};
// Base for every wasm test: a mocked host whose log is captured, and one way into the engine.
// Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test that
// holds it.
//
// Modules are written as WebAssembly text and assembled here. The assembler is in a
// test-only crate: the engine itself refuses text (`the_vm_refuses_a_text_format_module`),
// because a text assembler on the consensus path would make a transaction's validity a build
// flag.
// A free function because not every wasm test needs a host: `preflightEscrowWasm` takes none,
// so its fixture derives from `testing::Test` rather than from `WasmTest`.
inline Bytes
assembleWat(std::string_view wat)
{
auto const wasm = rs::wasm_testkit::compile_wat(rust::Str(wat.data(), wat.size()));
return Bytes{wasm.begin(), wasm.end()};
}
// Base for every wasm test that runs a contract: a mocked host whose log is captured, and one
// way into the engine.
//
// Modules are written as WebAssembly text and assembled by `assembleWat`. The assembler is in
// a test-only crate: the engine itself refuses text
// (`the_vm_refuses_a_text_format_module`), because a text assembler on the consensus path
// would make a transaction's validity a build flag.
class WasmTest : public testing::Test
{
protected:
@@ -77,13 +90,10 @@ protected:
EXPECT_CALL(host_, checkSelf()).WillRepeatedly(testing::Return(true));
}
// Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test
// that holds the fixture.
static Bytes
assemble(std::string_view wat)
{
auto const wasm = rs::wasm_testkit::compile_wat(rust::Str(wat.data(), wat.size()));
return Bytes{wasm.begin(), wasm.end()};
return assembleWat(wat);
}
std::expected<EscrowResult, WasmTER>