mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 14:20:56 +00:00
Implement ffi and host functions bindings
This commit is contained in:
@@ -96,7 +96,6 @@ find_package(OpenSSL REQUIRED)
|
||||
find_package(secp256k1 REQUIRED)
|
||||
find_package(SOCI REQUIRED)
|
||||
find_package(SQLite3 REQUIRED)
|
||||
find_package(wasmi REQUIRED)
|
||||
find_package(xxHash REQUIRED)
|
||||
|
||||
target_link_libraries(
|
||||
|
||||
@@ -67,7 +67,6 @@ target_link_libraries(
|
||||
Xrpl::opts
|
||||
Xrpl::syslibs
|
||||
secp256k1::secp256k1
|
||||
wasmi::wasmi
|
||||
xrpl.libpb
|
||||
xxHash::xxhash
|
||||
$<$<BOOL:${voidstar}>:antithesis-sdk-cpp>
|
||||
@@ -206,7 +205,17 @@ target_link_libraries(
|
||||
)
|
||||
|
||||
add_module(xrpl tx)
|
||||
target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger)
|
||||
# The wasm engine is a Rust crate reached over cxx: the bridge target supplies the
|
||||
# generated `lib.h` and `rust/cxx.h` that `tx/wasm` compiles against, and the Rust
|
||||
# static library everything downstream links. PUBLIC because the include path travels
|
||||
# with the module's own public headers.
|
||||
target_link_libraries(
|
||||
xrpl.libxrpl.tx
|
||||
PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge
|
||||
)
|
||||
# Those headers do not exist at configure time, and the header-verification target
|
||||
# compiles this module's headers on their own, so both need the crates built first.
|
||||
add_dependencies(xrpl.libxrpl.tx xrpl_crates)
|
||||
|
||||
add_module(xrpl consensus)
|
||||
target_link_libraries(
|
||||
|
||||
@@ -44,3 +44,17 @@ endfunction()
|
||||
|
||||
add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs)
|
||||
add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs)
|
||||
|
||||
# Test-only, and deliberately not part of xrpl_wasm_vm_ffi: it carries the `wat` assembler,
|
||||
# which the engine's `wasmi default-features = false` exists to keep out of the consensus
|
||||
# path. Linked from src/tests/libxrpl only, so the shipped node cannot contain it.
|
||||
add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs)
|
||||
|
||||
# The wasm bridge `include!`s a project header, so its generated translation unit needs
|
||||
# the project's include root. Deliberately only that: a header reached from here must
|
||||
# stay light enough to compile without the Boost paths this target does not get, which
|
||||
# is why `HostContext.h` forward-declares `xrpl::HostFunctions` instead of including it.
|
||||
target_include_directories(
|
||||
xrpl_wasm_vm_ffi_cxxbridge
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
10
crates/Cargo.lock
generated
10
crates/Cargo.lock
generated
@@ -477,6 +477,14 @@ dependencies = [
|
||||
"xrpl-host-functions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xrpl-wasm-testkit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xrpl-wasm-vm"
|
||||
version = "0.1.0"
|
||||
@@ -491,4 +499,6 @@ name = "xrpl-wasm-vm-ffi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
"xrpl-host-functions",
|
||||
"xrpl-wasm-vm",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["hello_world", "xrpl-wasm-vm-ffi", "xrpl-wasm-vm", "xrpl-host-functions", "xrpl-host-functions-macros"]
|
||||
members = ["hello_world", "xrpl-wasm-vm-ffi", "xrpl-wasm-vm", "xrpl-wasm-testkit", "xrpl-host-functions", "xrpl-host-functions-macros"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
11
crates/xrpl-wasm-testkit/Cargo.toml
Normal file
11
crates/xrpl-wasm-testkit/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "xrpl-wasm-testkit"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
cxx.workspace = true
|
||||
wat = "1"
|
||||
49
crates/xrpl-wasm-testkit/src/lib.rs
Normal file
49
crates/xrpl-wasm-testkit/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Assembles WebAssembly text for the C++ test suite. **Test-only.**
|
||||
//!
|
||||
//! A crate of its own rather than an entry on `xrpl-wasm-vm-ffi`, and the separation is the
|
||||
//! point. The engine pins `wasmi = { default-features = false }` precisely so a text
|
||||
//! assembler cannot reach the consensus path — wasmi's `wat` feature is on by default and
|
||||
//! makes `Module::new` accept text as readily as binary, which would make a transaction's
|
||||
//! validity a build flag (review finding A5). Putting `compile_wat` on the production bridge
|
||||
//! would link `wat` into xrpld even if nothing called it.
|
||||
//!
|
||||
//! Linked only into `xrpl_tests`, never into `libxrpl` or `xrpld`, so "no assembler in the
|
||||
//! shipped node" is a property of the link graph rather than a flag someone can flip.
|
||||
#![deny(rustdoc::broken_intra_doc_links)]
|
||||
|
||||
#[cxx::bridge(namespace = "rs::wasm_testkit")]
|
||||
mod ffi {
|
||||
extern "Rust" {
|
||||
/// Assemble `wat` to a wasm module.
|
||||
///
|
||||
/// Throws `rust::Error` on invalid input, which is what a test wants: a typo in a
|
||||
/// fixture should fail the test that holds it, at the line that holds it.
|
||||
fn compile_wat(wat: &str) -> Result<Vec<u8>>;
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_wat(wat: &str) -> Result<Vec<u8>, wat::Error> {
|
||||
wat::parse_str(wat)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compile_wat;
|
||||
|
||||
#[test]
|
||||
fn a_module_assembles_to_something_beginning_with_the_wasm_magic() {
|
||||
let wasm = compile_wat("(module)").expect("assembles");
|
||||
|
||||
assert_eq!(&wasm[..4], b"\0asm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typo_is_an_error_rather_than_a_module() {
|
||||
let error = compile_wat("(module (func (export").expect_err("must not assemble");
|
||||
|
||||
assert!(
|
||||
!error.to_string().is_empty(),
|
||||
"the error has to say something"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
cxx.workspace = true
|
||||
xrpl-host-functions = { path = "../xrpl-host-functions" }
|
||||
xrpl-wasm-vm = { path = "../xrpl-wasm-vm" }
|
||||
|
||||
@@ -1,2 +1,397 @@
|
||||
#[cxx::bridge]
|
||||
mod ffi {}
|
||||
//! The cxx bridge between the escrow wasm engine and xrpld.
|
||||
//!
|
||||
//! Two 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.
|
||||
//!
|
||||
//! **Neither direction may unwind into the other**, and the two halves of that are
|
||||
//! not symmetric:
|
||||
//!
|
||||
//! - A **Rust panic** is caught here, by `guarded`. Letting one reach C++ is
|
||||
//! undefined behaviour; `[profile.release]` turns overflow checks on, so this is a
|
||||
//! live path and not a formality.
|
||||
//! - A **C++ exception** is stopped on the C++ side: every `HostContext` method is
|
||||
//! `noexcept` and reports failure as a negative `HostError` code. That is what
|
||||
//! makes `guarded` sufficient — see its documentation.
|
||||
//!
|
||||
//! Everything hand-written here is private, so the names above are code spans rather
|
||||
//! than links, and `cargo doc` needs `--document-private-items` to show any of it.
|
||||
//! That is also why this crate, unlike `xrpl-wasm-vm`, does not
|
||||
//! `deny(unreachable_pub)`: cxx's expansion is `pub` throughout by necessity, leaving
|
||||
//! the lint nothing but generated code to fire on.
|
||||
#![deny(rustdoc::broken_intra_doc_links)]
|
||||
|
||||
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};
|
||||
|
||||
/// [`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
|
||||
/// `tecINTERNAL`.
|
||||
#[cfg(panic = "abort")]
|
||||
compile_error!(
|
||||
"xrpl-wasm-vm-ffi requires panic=unwind: run_escrow catches panics rather than \
|
||||
letting them cross into C++"
|
||||
);
|
||||
|
||||
#[cxx::bridge(namespace = "rs::wasm_vm")]
|
||||
mod ffi {
|
||||
/// Which outcome a run had — one variant per way [`run`] can end, so the caller
|
||||
/// maps a status to a TER rather than reading a message.
|
||||
#[derive(Debug, Hash)]
|
||||
#[repr(i32)]
|
||||
enum RunStatus {
|
||||
/// The entry point returned.
|
||||
Ok,
|
||||
/// `wasm` is not a valid module under this engine's configuration.
|
||||
Compile,
|
||||
/// The module would not instantiate.
|
||||
Instantiate,
|
||||
/// No export of that name with signature `() -> i32`.
|
||||
EntryPoint,
|
||||
/// Gas exhausted, by the guest's instructions or a host call's charge.
|
||||
OutOfGas,
|
||||
/// The host could not serve a call, including any exception it caught.
|
||||
Internal,
|
||||
/// A host call had no linear memory to work in.
|
||||
NoMemory,
|
||||
/// The guest trapped.
|
||||
Trap,
|
||||
/// The engine panicked. A defect in this crate or the one below it.
|
||||
Panic,
|
||||
}
|
||||
|
||||
/// A run's outcome, flattened: cxx enums carry no payload, so the status, the
|
||||
/// cost and the description travel side by side.
|
||||
struct RunResult {
|
||||
status: RunStatus,
|
||||
/// What the entry point returned. Meaningful only when `status` is `Ok`.
|
||||
result: i32,
|
||||
/// Gas consumed. The whole limit when gas ran out; `0` when the module never
|
||||
/// ran, or when the cost could not be trusted (`Internal`, `Panic`).
|
||||
gas_used: u64,
|
||||
/// The engine's own description of the outcome, for the log. Empty on `Ok`.
|
||||
detail: String,
|
||||
}
|
||||
|
||||
extern "Rust" {
|
||||
/// Run `wasm`'s `function_name` export with `gas` fuel, servicing host calls
|
||||
/// through `host`.
|
||||
///
|
||||
/// Reports every outcome as a [`RunStatus`] and **never throws**: an
|
||||
/// exception is a poor interface for a condition the caller has to turn into
|
||||
/// a TER anyway, and a panic reaching C++ would be undefined behaviour.
|
||||
///
|
||||
/// `gas` is the run's whole budget. `0` is a run that cannot execute an
|
||||
/// 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;
|
||||
}
|
||||
|
||||
unsafe extern "C++" {
|
||||
include!("xrpl/tx/wasm/HostContext.h");
|
||||
|
||||
/// The C++ side of the ABI: one method per host function, forwarding to
|
||||
/// `xrpl::HostFunctions`.
|
||||
///
|
||||
/// Every method is `noexcept` and answers with a code, so a host call cannot
|
||||
/// unwind into the engine.
|
||||
///
|
||||
/// `cxx_name` on each method below is not cosmetic: the declarations keep the
|
||||
/// ABI's names here and rippled's camelBack over there, so neither side has
|
||||
/// to spell the other's convention.
|
||||
#[namespace = "xrpl"]
|
||||
type HostContext;
|
||||
|
||||
/// A byte-producing call is handed `out` and returns the value's **true
|
||||
/// length**, writing it only if the whole value fits. Returning a length past
|
||||
/// `out` is how a guest learns the size to ask for; the engine turns it into
|
||||
/// `BufferTooSmall`, so C++ never needs to know the guest's capacity.
|
||||
///
|
||||
/// A negative return is a `HostError` code.
|
||||
#[namespace = "xrpl"]
|
||||
#[cxx_name = "getLedgerSqn"]
|
||||
fn get_ledger_sqn(self: &HostContext, out: &mut [u8]) -> i32;
|
||||
|
||||
#[namespace = "xrpl"]
|
||||
#[cxx_name = "getCurrentLedgerObjField"]
|
||||
fn get_current_ledger_obj_field(self: &HostContext, field: i32, out: &mut [u8]) -> i32;
|
||||
|
||||
#[namespace = "xrpl"]
|
||||
#[cxx_name = "sha512Half"]
|
||||
fn sha512_half(self: &HostContext, data: &[u8], out: &mut [u8]) -> i32;
|
||||
|
||||
/// A call with no value to report answers `0`, or a negative `HostError`
|
||||
/// code.
|
||||
#[namespace = "xrpl"]
|
||||
fn trace(self: &HostContext, msg: &str, data: &[u8], as_hex: bool) -> i32;
|
||||
|
||||
#[namespace = "xrpl"]
|
||||
#[cxx_name = "traceNum"]
|
||||
fn trace_num(self: &HostContext, msg: &str, number: i64) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sized carrier for the [`HostFunctions`] implementation.
|
||||
///
|
||||
/// [`ffi::HostContext`] is an opaque C++ type and therefore `!Sized`, so it cannot
|
||||
/// be coerced to `&dyn HostFunctions` itself.
|
||||
struct CxxHost<'a> {
|
||||
ctx: &'a ffi::HostContext,
|
||||
}
|
||||
|
||||
/// A byte-producing call's answer: the value's true length, or its error code.
|
||||
///
|
||||
/// The conversion *is* the sign test — it fails on exactly the negative values — so
|
||||
/// there is no cast to argue about.
|
||||
fn bytes_written(n: i32) -> HostResult<usize> {
|
||||
usize::try_from(n).map_err(|_| HostError::from_code(n))
|
||||
}
|
||||
|
||||
/// A call with nothing to report: any non-negative answer is success.
|
||||
fn reported(n: i32) -> HostResult<()> {
|
||||
if n < 0 {
|
||||
return Err(HostError::from_code(n));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl HostFunctions for CxxHost<'_> {
|
||||
fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize> {
|
||||
bytes_written(self.ctx.get_ledger_sqn(out))
|
||||
}
|
||||
|
||||
fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult<usize> {
|
||||
bytes_written(self.ctx.get_current_ledger_obj_field(field, out))
|
||||
}
|
||||
|
||||
fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult<usize> {
|
||||
bytes_written(self.ctx.sha512_half(data, out))
|
||||
}
|
||||
|
||||
fn trace(&self, msg: &str, data: &[u8], as_hex: bool) -> HostResult<()> {
|
||||
reported(self.ctx.trace(msg, data, as_hex))
|
||||
}
|
||||
|
||||
fn trace_num(&self, msg: &str, number: i64) -> HostResult<()> {
|
||||
reported(self.ctx.trace_num(msg, number))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_escrow(
|
||||
host: &ffi::HostContext,
|
||||
wasm: &[u8],
|
||||
gas: u64,
|
||||
function_name: &str,
|
||||
) -> ffi::RunResult {
|
||||
guarded(|| {
|
||||
let host = CxxHost { ctx: host };
|
||||
flatten(run(wasm, gas, &host, function_name))
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `body`, turning a panic into [`ffi::RunStatus::Panic`] 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
|
||||
/// wasmi are Rust, and a host call cannot start a C++ unwind because each
|
||||
/// `HostContext` method is `noexcept` and answers with a code. So the only unwind
|
||||
/// that can reach this frame started in Rust, and this stops it.
|
||||
///
|
||||
/// [`AssertUnwindSafe`] is sound because nothing survives to be observed in a torn
|
||||
/// state: the store, the linker and the host wrapper are all dropped on the way out,
|
||||
/// 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),
|
||||
})
|
||||
}
|
||||
|
||||
/// The panic's message, for the log.
|
||||
///
|
||||
/// A `panic!` payload is a `&str` or a `String`; anything else is a `panic_any` that
|
||||
/// nothing below this crate makes, and it still has to produce a line.
|
||||
fn panic_detail(payload: &(dyn Any + Send)) -> String {
|
||||
let message = payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("payload is not a 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 status a [`RunError`] crosses as.
|
||||
///
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// These tests reach none of the `extern "C++"` methods, which is what lets the test
|
||||
/// binary link at all: the C++ side of the bridge exists only in the CMake build, so
|
||||
/// a test that called one would fail to link rather than fail.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ok(result: i32, fuel_used: u64) -> ffi::RunResult {
|
||||
flatten(Ok(RunOutcome { result, fuel_used }))
|
||||
}
|
||||
|
||||
fn failed(error: RunError, fuel_used: u64) -> ffi::RunResult {
|
||||
flatten(Err(RunFailure { error, fuel_used }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_run_carries_its_value_and_its_cost() {
|
||||
let crossed = ok(5, 1234);
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Ok);
|
||||
assert_eq!(crossed.result, 5);
|
||||
assert_eq!(crossed.gas_used, 1234);
|
||||
assert_eq!(crossed.detail, "", "a completed run has nothing to explain");
|
||||
}
|
||||
|
||||
/// The cost is the point: a contract that burns its gas and traps is charged.
|
||||
#[test]
|
||||
fn a_failed_run_carries_its_cost_and_the_engines_own_words() {
|
||||
let crossed = failed(RunError::Trap("unreachable".to_string()), 900);
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Trap);
|
||||
assert_eq!(crossed.gas_used, 900);
|
||||
assert_eq!(crossed.detail, "trap: unreachable");
|
||||
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.
|
||||
fn every_run_error() -> Vec<RunError> {
|
||||
vec![
|
||||
RunError::Compile(String::new()),
|
||||
RunError::Instantiate(String::new()),
|
||||
RunError::EntryPoint(String::new()),
|
||||
RunError::OutOfGas,
|
||||
RunError::Internal,
|
||||
RunError::NoMemory,
|
||||
RunError::Trap(String::new()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Distinct statuses, because the TER map on the far side reads nothing else. Two
|
||||
/// outcomes sharing one status would silently collapse two TERs into one.
|
||||
#[test]
|
||||
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);
|
||||
assert!(
|
||||
!seen.contains(&status),
|
||||
"{error:?} shares {status:?} with an earlier outcome"
|
||||
);
|
||||
seen.push(status);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ok` is the one status no failure may take: the far side reads it as "the
|
||||
/// contract returned", and would then read `result` off a run that produced none.
|
||||
#[test]
|
||||
fn no_failure_crosses_as_success() {
|
||||
for error in every_run_error() {
|
||||
assert_ne!(status_of(&error), ffi::RunStatus::Ok, "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_a_status_instead_of_an_unwind() {
|
||||
let crossed = guarded(|| panic!("the engine came apart"));
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Panic);
|
||||
assert_eq!(crossed.detail, "panicked: the engine came apart");
|
||||
assert_eq!(crossed.gas_used, 0, "a panicking run reports no cost");
|
||||
}
|
||||
|
||||
/// A formatted `panic!` payload is a `String` rather than a `&str`, so both
|
||||
/// downcasts are load-bearing.
|
||||
#[test]
|
||||
fn a_formatted_panic_keeps_its_message() {
|
||||
let overflowed = 3;
|
||||
let crossed = guarded(|| panic!("gas underflowed by {overflowed}"));
|
||||
|
||||
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));
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Panic);
|
||||
assert_eq!(crossed.detail, "panicked: payload is not a string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_does_not_panic_is_untouched() {
|
||||
let crossed = guarded(|| ok(1, 2));
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Ok);
|
||||
assert_eq!(crossed.result, 1);
|
||||
assert_eq!(crossed.gas_used, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_answer_is_an_error_code_and_a_length_is_a_length() {
|
||||
assert_eq!(bytes_written(32), Ok(32));
|
||||
assert_eq!(bytes_written(0), Ok(0));
|
||||
assert_eq!(bytes_written(-3), Err(HostError::BufferTooSmall));
|
||||
assert_eq!(reported(0), Ok(()));
|
||||
assert_eq!(reported(-14), Err(HostError::NoMemExported));
|
||||
}
|
||||
|
||||
/// An exception caught on the C++ side arrives as `-1`, which has to reach the
|
||||
/// engine as a *fatal* error so the run stops and the transaction is
|
||||
/// `tecINTERNAL` — not as a code handed to the contract to interpret.
|
||||
#[test]
|
||||
fn a_caught_cxx_exception_arrives_as_internal() {
|
||||
assert_eq!(bytes_written(-1), Err(HostError::Internal));
|
||||
assert_eq!(reported(-1), Err(HostError::Internal));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
61
include/xrpl/tx/wasm/HostContext.h
Normal file
61
include/xrpl/tx/wasm/HostContext.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <rust/cxx.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl {
|
||||
// `xrpl::HostFunctions` is forward-declared rather than included: this header is
|
||||
// `include!()`d by the cxxbridge-generated translation unit, whose target gets only the
|
||||
// project's `include/` directory - not the Boost paths that HostFunc.h -> Slice.h ->
|
||||
// strHex.h transitively need. A reference member and declarations alone do not require a
|
||||
// complete type; HostContext.cpp, compiled into libxrpl, includes the real header.
|
||||
class HostFunctions;
|
||||
|
||||
// The host handed to the Rust wasm engine: one method per entry in the wasm host ABI,
|
||||
// each forwarding to `xrpl::HostFunctions` - the single source of truth for ledger
|
||||
// access - and lowering its typed `std::expected` result onto the ABI's wire form.
|
||||
//
|
||||
// Every method is `noexcept`, and every body catches everything: a C++ exception
|
||||
// unwinding into the Rust frames that called it would be undefined behaviour, so a
|
||||
// failure leaves here as -1, which the engine reads as a fatal error and reports as
|
||||
// `tecINTERNAL`.
|
||||
//
|
||||
// Not an owner: it borrows `hf` for the length of one run. Declared `struct` because the
|
||||
// Rust side only ever sees an opaque pointer.
|
||||
class HostContext
|
||||
{
|
||||
// Non-const so a host function that mutates (`cacheLedgerObj`, `updateData`) can be
|
||||
// reached from the `const` methods below: constness of the reference is not
|
||||
// constness of the referent.
|
||||
HostFunctions& hostFunctions_;
|
||||
|
||||
public:
|
||||
HostContext(HostFunctions& hostFunctions);
|
||||
|
||||
// A byte-producing call is handed `out` - a slice aliasing either guest linear
|
||||
// memory or the engine's output buffer - writes the value only if the whole of it
|
||||
// fits, and returns the value's *true* length, which may exceed `out`. That is how a
|
||||
// guest learns the size to ask for, and it is why these methods never need to know
|
||||
// the guest's capacity: the engine owns the buffer-fit, field-cap and transfer-budget
|
||||
// rules and derives all three from the length returned here.
|
||||
//
|
||||
// A negative return is a `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerSqn(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjField(std::int32_t field, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
sha512Half(rust::Slice<std::uint8_t const> data, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// A call with no value to report answers 0, or a negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
trace(rust::Str msg, rust::Slice<std::uint8_t const> data, bool asHex) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
traceNum(rust::Str msg, std::int64_t number) const noexcept;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -1,254 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
|
||||
#include <wasm.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
#define WASM_CB_PARAMS_LIST void *env, wasm_val_vec_t const *params, wasm_val_vec_t *results
|
||||
#define WASM_SECONDARY_CB_PARAMS_LIST \
|
||||
HostFunctions &hf, wasm_val_vec_t const *params, wasm_val_vec_t *results
|
||||
|
||||
wasm_trap_t* HostFuncMain_wrap(WASM_CB_PARAMS_LIST);
|
||||
|
||||
using getLedgerSqn_proto = int32_t(uint8_t*, int32_t);
|
||||
wasm_trap_t* getLedgerSqn_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getParentLedgerTime_proto = int32_t(uint8_t*, int32_t);
|
||||
wasm_trap_t* getParentLedgerTime_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getParentLedgerHash_proto = int32_t(uint8_t*, int32_t);
|
||||
wasm_trap_t* getParentLedgerHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getBaseFee_proto = int32_t(uint8_t*, int32_t);
|
||||
wasm_trap_t* getBaseFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using isAmendmentEnabled_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* isAmendmentEnabled_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using cacheLedgerObj_proto = int32_t(uint8_t const*, int32_t, int32_t);
|
||||
wasm_trap_t* cacheLedgerObj_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getTxField_proto = int32_t(int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getTxField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getCurrentLedgerObjField_proto = int32_t(int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getCurrentLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getLedgerObjField_proto = int32_t(int32_t, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getLedgerObjField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getTxNestedField_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getTxNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getCurrentLedgerObjNestedField_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getCurrentLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getLedgerObjNestedField_proto = int32_t(int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getLedgerObjNestedField_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getTxArrayLen_proto = int32_t(int32_t);
|
||||
wasm_trap_t* getTxArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getCurrentLedgerObjArrayLen_proto = int32_t(int32_t);
|
||||
wasm_trap_t* getCurrentLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getLedgerObjArrayLen_proto = int32_t(int32_t, int32_t);
|
||||
wasm_trap_t* getLedgerObjArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getTxNestedArrayLen_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* getTxNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getCurrentLedgerObjNestedArrayLen_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* getCurrentLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getLedgerObjNestedArrayLen_proto = int32_t(int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* getLedgerObjNestedArrayLen_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using updateData_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* updateData_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using checkSignature_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* checkSignature_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using computeSha512HalfHash_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* computeSha512HalfHash_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using accountKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* accountKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using ammKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* ammKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using checkKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* checkKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using credentialKeylet_proto = int32_t(
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t*,
|
||||
int32_t);
|
||||
wasm_trap_t* credentialKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using delegateKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* delegateKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using depositPreauthKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* depositPreauthKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using didKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* didKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using escrowKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* escrowKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using trustLineKeylet_proto = int32_t(
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t*,
|
||||
int32_t);
|
||||
wasm_trap_t* trustLineKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using mptokenIssuanceKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* mptokenIssuanceKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using mptokenKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* mptokenKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using nftokenOfferKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* nftokenOfferKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using offerKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* offerKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using oracleKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* oracleKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using paychannelKeylet_proto = int32_t(
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t const*,
|
||||
int32_t,
|
||||
uint8_t*,
|
||||
int32_t);
|
||||
wasm_trap_t* paychannelKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using permissionedDomainKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* permissionedDomainKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using signerListKeylet_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* signerListKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using ticketKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* ticketKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using vaultKeylet_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* vaultKeylet_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFT_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getNFT_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFTIssuer_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getNFTIssuer_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFTTaxon_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getNFTTaxon_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFTFlags_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* getNFTFlags_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFTTransferFee_proto = int32_t(uint8_t const*, int32_t);
|
||||
wasm_trap_t* getNFTTransferFee_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using getNFTSequence_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* getNFTSequence_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using trace_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, int32_t);
|
||||
wasm_trap_t* trace_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using traceNum_proto = int32_t(uint8_t const*, int32_t, int64_t);
|
||||
wasm_trap_t* traceNum_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using traceAccount_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* traceAccount_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using traceFloat_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* traceFloat_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using traceAmount_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* traceAmount_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatFromInt_proto = int32_t(int64_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatFromInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatFromUint_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatFromUint_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatFromSTAmount_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatFromSTAmount_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatFromSTNumber_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatFromSTNumber_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatToInt_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatToInt_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatToMantExp_proto = int32_t(uint8_t const*, int32_t, uint8_t*, int32_t, uint8_t*, int32_t);
|
||||
wasm_trap_t* floatToMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatFromMantExp_proto = int32_t(int64_t, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatFromMantExp_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatCompare_proto = int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t);
|
||||
wasm_trap_t* floatCompare_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatAdd_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatAdd_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatSubtract_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatSubtract_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatMultiply_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatMultiply_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatDivide_proto =
|
||||
int32_t(uint8_t const*, int32_t, uint8_t const*, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatDivide_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatRoot_proto = int32_t(uint8_t const*, int32_t, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatRoot_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
using floatPower_proto = int32_t(uint8_t const*, int32_t, int32_t, uint8_t*, int32_t, int32_t);
|
||||
wasm_trap_t* floatPower_wrap(WASM_SECONDARY_CB_PARAMS_LIST);
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -1,126 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <boost/function_types/function_arity.hpp>
|
||||
#include <boost/function_types/parameter_types.hpp>
|
||||
#include <boost/function_types/result_type.hpp>
|
||||
#include <boost/mpl/vector.hpp>
|
||||
|
||||
#include <wasm.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace bft = boost::function_types;
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
using wasmSecondaryCbFuncType =
|
||||
wasm_trap_t*(HostFunctions&, wasm_val_vec_t const*, wasm_val_vec_t*);
|
||||
|
||||
struct WasmImportFunc
|
||||
{
|
||||
std::string_view name;
|
||||
std::optional<WasmTypes> result;
|
||||
std::vector<WasmTypes> params;
|
||||
|
||||
wasmSecondaryCbFuncType* wrap = nullptr;
|
||||
uint32_t gas = 0;
|
||||
};
|
||||
|
||||
using WasmUserData = std::pair<HFRef, WasmImportFunc>;
|
||||
// string - import function name
|
||||
using ImportVec = std::unordered_map<std::string_view, WasmUserData>;
|
||||
|
||||
template <int N, int C, typename Mpl>
|
||||
void
|
||||
WasmImpArgs(WasmImportFunc& e)
|
||||
{
|
||||
if constexpr (N < C)
|
||||
{
|
||||
using at = boost::mpl::at_c<Mpl, N>::type;
|
||||
if constexpr (std::is_pointer_v<at> || std::is_same_v<at, std::int32_t>)
|
||||
{
|
||||
e.params.push_back(WasmTypes::WtI32);
|
||||
}
|
||||
else if constexpr (std::is_same_v<at, std::int64_t>)
|
||||
{
|
||||
e.params.push_back(WasmTypes::WtI64);
|
||||
}
|
||||
else
|
||||
{
|
||||
static_assert(std::is_pointer_v<at>, "Unsupported argument type");
|
||||
}
|
||||
|
||||
return WasmImpArgs<N + 1, C, Mpl>(e);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename>
|
||||
inline constexpr bool wasmDependentFalse = false;
|
||||
|
||||
template <typename Rt>
|
||||
void
|
||||
WasmImpRet(WasmImportFunc& e)
|
||||
{
|
||||
if constexpr (std::is_pointer_v<Rt> || std::is_same_v<Rt, std::int32_t>)
|
||||
{
|
||||
e.result = WasmTypes::WtI32;
|
||||
}
|
||||
else if constexpr (std::is_same_v<Rt, std::int64_t>)
|
||||
{
|
||||
e.result = WasmTypes::WtI64;
|
||||
}
|
||||
else if constexpr (std::is_void_v<Rt>)
|
||||
{
|
||||
e.result.reset();
|
||||
}
|
||||
else
|
||||
{
|
||||
static_assert(wasmDependentFalse<Rt>, "Unsupported return type");
|
||||
}
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
void
|
||||
WasmImpFuncHelper(WasmImportFunc& e)
|
||||
{
|
||||
using rt = bft::result_type<F>::type;
|
||||
using pt = bft::parameter_types<F>::type;
|
||||
// typename boost::mpl::at_c<mpl, N>::type
|
||||
|
||||
WasmImpRet<rt>(e);
|
||||
WasmImpArgs<0, bft::function_arity<F>::value, pt>(e);
|
||||
// WasmImpWrap(e, std::forward<F>(f));
|
||||
}
|
||||
|
||||
// imp_name - string literal, must have static lifetime
|
||||
template <typename F>
|
||||
void
|
||||
WasmImpFunc(
|
||||
ImportVec& v,
|
||||
std::string_view impName,
|
||||
wasmSecondaryCbFuncType* fWrap,
|
||||
HostFunctions& hf,
|
||||
uint32_t gas = 0)
|
||||
{
|
||||
WasmImportFunc e;
|
||||
e.name = impName;
|
||||
e.wrap = fWrap;
|
||||
e.gas = gas;
|
||||
WasmImpFuncHelper<F>(e);
|
||||
v.emplace(impName, std::make_pair(HFRef(hf), std::move(e)));
|
||||
}
|
||||
|
||||
#define WASM_IMPORT_FUNC(v, f, ...) WasmImpFunc<f##_proto>(v, #f, &f##_wrap, ##__VA_ARGS__)
|
||||
|
||||
// n - string literal name, must have static lifetime
|
||||
#define WASM_IMPORT_FUNC2(v, f, n, ...) WasmImpFunc<f##_proto>(v, n, &f##_wrap, ##__VA_ARGS__)
|
||||
|
||||
} // namespace xrpl
|
||||
33
include/xrpl/tx/wasm/WasmVM.h
Normal file
33
include/xrpl/tx/wasm/WasmVM.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// The export a programmable escrow's contract is run through.
|
||||
std::string_view inline constexpr escrowFunctionName = "escrow_finish";
|
||||
|
||||
// Run `wasmCode`'s `funcName` export with `gasLimit` gas, servicing its host calls
|
||||
// through `hfs`.
|
||||
//
|
||||
// On success the result is what the contract returned - positive means the escrow may
|
||||
// finish - together with the gas it consumed. On failure it is the TER to apply and,
|
||||
// when the number means anything, the gas to write to transaction metadata: a contract
|
||||
// that traps or exhausts its budget is charged for what it burned, while a `tecINTERNAL`
|
||||
// reports no cost because the fault is the node's rather than the transaction's.
|
||||
//
|
||||
// Does not throw. Every way a run can end - including a Rust panic inside the engine or
|
||||
// a C++ exception thrown by a host function - arrives as one of those two answers.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
HostFunctions& hfs,
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName = escrowFunctionName);
|
||||
|
||||
} // namespace xrpl
|
||||
166
src/libxrpl/tx/wasm/HostContext.cpp
Normal file
166
src/libxrpl/tx/wasm/HostContext.cpp
Normal file
@@ -0,0 +1,166 @@
|
||||
#include <xrpl/tx/wasm/HostContext.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <source_location>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
// What a host call answers when it could not be served at all. The engine reads -1 as its
|
||||
// fatal `Internal`, stops the run and reports `tecINTERNAL`.
|
||||
//
|
||||
// `HostFunctionError` spells -1 `Unimplemented`, so the two share a code. They also share
|
||||
// a meaning worth keeping together - "the host could not serve this call, and the contract
|
||||
// has no business interpreting why" - and they must share a fate. Named here so a call
|
||||
// site reads as what it is rather than as "unimplemented".
|
||||
constexpr std::int32_t kHostInternal = hfErrorToInt(HostFunctionError::Unimplemented);
|
||||
|
||||
// Nothing may unwind out of a host call: the frames that called it are Rust, which cannot
|
||||
// run a C++ landing pad. Every method below goes through here, so the catch is not a thing
|
||||
// any one of them can forget.
|
||||
//
|
||||
// The caller names itself: the default argument is evaluated at the call site, so the log
|
||||
// line gets the enclosing method without anyone passing a string that could drift from the
|
||||
// method it labels. `__func__` would expand to `operator()` inside the lambda, which is why
|
||||
// this is a defaulted parameter rather than something the body reads.
|
||||
template <class Body>
|
||||
std::int32_t
|
||||
guarded(
|
||||
beast::Journal journal,
|
||||
Body&& body,
|
||||
std::source_location const location = std::source_location::current()) noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
return body();
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(journal.warn()) << "wasm host call threw in " << location.function_name() << ": "
|
||||
<< e.what();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(journal.warn())
|
||||
<< "wasm host call threw a non-exception in " << location.function_name();
|
||||
}
|
||||
|
||||
return kHostInternal;
|
||||
}
|
||||
|
||||
// Copy `value` into `out` only if the whole of it fits, and answer its true length either
|
||||
// way. A value too large for the guest's buffer must reach it in no part: a prefix would
|
||||
// be a wrong answer where a length is a usable one.
|
||||
std::int32_t
|
||||
answer(rust::Slice<std::uint8_t> out, std::uint8_t const* value, std::size_t size)
|
||||
{
|
||||
if (size <= out.size())
|
||||
std::memcpy(out.data(), value, size);
|
||||
return static_cast<std::int32_t>(size);
|
||||
}
|
||||
|
||||
// A scalar the ABI carries as bytes, in the wire's byte order.
|
||||
//
|
||||
// `adjustWasmEndianess` is the one place that order is decided for the whole wasm boundary,
|
||||
// and it is `constexpr` with the swap under `if constexpr (std::endian::native ==
|
||||
// std::endian::big)` - so this costs nothing on a little-endian host and is correct on a
|
||||
// big-endian one, which a hand-written shift sequence per call site would have to get right
|
||||
// each time.
|
||||
template <class T>
|
||||
std::int32_t
|
||||
answerScalar(rust::Slice<std::uint8_t> out, T value)
|
||||
{
|
||||
auto const wire = adjustWasmEndianess(value);
|
||||
return answer(out, reinterpret_cast<std::uint8_t const*>(&wire), sizeof(wire));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HostContext::HostContext(HostFunctions& hostFunctions) : hostFunctions_(hostFunctions)
|
||||
{
|
||||
}
|
||||
|
||||
std::int32_t
|
||||
HostContext::getLedgerSqn(rust::Slice<std::uint8_t> out) const noexcept
|
||||
{
|
||||
return guarded(hostFunctions_.getJournal(), [&] {
|
||||
auto const sqn = hostFunctions_.getLedgerSqn();
|
||||
if (!sqn)
|
||||
return hfErrorToInt(sqn.error());
|
||||
|
||||
// Four bytes the guest reads back with `u32::from_le_bytes`.
|
||||
return answerScalar(out, *sqn);
|
||||
});
|
||||
}
|
||||
|
||||
std::int32_t
|
||||
HostContext::getCurrentLedgerObjField(std::int32_t field, rust::Slice<std::uint8_t> out)
|
||||
const noexcept
|
||||
{
|
||||
return guarded(hostFunctions_.getJournal(), [&] {
|
||||
auto const& knownSFields = SField::getKnownCodeToField();
|
||||
auto const it = knownSFields.find(field);
|
||||
if (it == knownSFields.end())
|
||||
return hfErrorToInt(HostFunctionError::InvalidField);
|
||||
|
||||
auto const value = hostFunctions_.getCurrentLedgerObjField(*it->second);
|
||||
if (!value)
|
||||
return hfErrorToInt(value.error());
|
||||
|
||||
return answer(out, value->data(), value->size());
|
||||
});
|
||||
}
|
||||
|
||||
std::int32_t
|
||||
HostContext::sha512Half(rust::Slice<std::uint8_t const> data, rust::Slice<std::uint8_t> out)
|
||||
const noexcept
|
||||
{
|
||||
return guarded(hostFunctions_.getJournal(), [&] {
|
||||
auto const digest = hostFunctions_.computeSha512HalfHash(Slice(data.data(), data.size()));
|
||||
if (!digest)
|
||||
return hfErrorToInt(digest.error());
|
||||
|
||||
return answer(out, digest->data(), digest->size());
|
||||
});
|
||||
}
|
||||
|
||||
std::int32_t
|
||||
HostContext::trace(rust::Str msg, rust::Slice<std::uint8_t const> data, bool asHex) const noexcept
|
||||
{
|
||||
return guarded(hostFunctions_.getJournal(), [&] {
|
||||
auto const status = hostFunctions_.trace(
|
||||
std::string_view(msg.data(), msg.size()), Slice(data.data(), data.size()), asHex);
|
||||
if (!status)
|
||||
return hfErrorToInt(status.error());
|
||||
|
||||
return *status;
|
||||
});
|
||||
}
|
||||
|
||||
std::int32_t
|
||||
HostContext::traceNum(rust::Str msg, std::int64_t number) const noexcept
|
||||
{
|
||||
return guarded(hostFunctions_.getJournal(), [&] {
|
||||
auto const status =
|
||||
hostFunctions_.traceNum(std::string_view(msg.data(), msg.size()), number);
|
||||
if (!status)
|
||||
return hfErrorToInt(status.error());
|
||||
|
||||
return *status;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
130
src/libxrpl/tx/wasm/WasmVM.cpp
Normal file
130
src/libxrpl/tx/wasm/WasmVM.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostContext.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <xrpl_wasm_vm_ffi_cxxbridge/lib.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
using RunStatus = rs::wasm_vm::RunStatus;
|
||||
|
||||
// The engine's outcome as the caller's: a value with its cost, or a TER with the cost to
|
||||
// record beside it.
|
||||
//
|
||||
// A `tecINTERNAL` reports no cost. It says the fault is the node's, and charging a
|
||||
// transaction for a node's defect would write that defect into the ledger.
|
||||
//
|
||||
// Exhaustive over the status enum, with no `default`: the enum is generated from the
|
||||
// engine's `RunError`, so an outcome added there fails this switch under -Wswitch -Werror
|
||||
// rather than quietly picking up a neighbour's TER.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
outcome(rs::wasm_vm::RunResult const& run)
|
||||
{
|
||||
auto const cost = static_cast<std::int64_t>(run.gas_used);
|
||||
|
||||
switch (run.status)
|
||||
{
|
||||
case RunStatus::Ok:
|
||||
return EscrowResult{.result = run.result, .cost = cost};
|
||||
|
||||
// The cost is the whole limit: XLS-0102 halts the guest the instant the meter runs
|
||||
// out, and the run is charged for all of it.
|
||||
case RunStatus::OutOfGas:
|
||||
return std::unexpected(WasmTER{.ter = tecOUT_OF_GAS, .cost = cost});
|
||||
|
||||
// The contract's own fault - it trapped, or it never exported the linear memory
|
||||
// its host calls need - so it is charged for what it burned reaching that point.
|
||||
case RunStatus::Trap:
|
||||
case RunStatus::NoMemory:
|
||||
return std::unexpected(WasmTER{.ter = tecFAILED_PROCESSING, .cost = cost});
|
||||
|
||||
// A module that will not compile, instantiate, or expose the entry point should
|
||||
// have been refused at preflight with `temBAD_WASM`. Reaching apply means the
|
||||
// screening did not happen, which is a node-side fault rather than the
|
||||
// transaction's.
|
||||
case RunStatus::Compile:
|
||||
case RunStatus::Instantiate:
|
||||
case RunStatus::EntryPoint:
|
||||
// The host could not serve a call, or it threw and `HostContext` caught it.
|
||||
case RunStatus::Internal:
|
||||
// The engine panicked: a defect in the engine, reported rather than fatal to the
|
||||
// node.
|
||||
case RunStatus::Panic:
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
}
|
||||
|
||||
// Not reachable through the enum, but a value outside it is representable.
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
HostFunctions& hfs,
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName)
|
||||
{
|
||||
// A run needs a budget to spend. Refused here rather than in the engine because what a
|
||||
// non-positive limit means is a transaction-validity rule; the engine's own budget is
|
||||
// therefore an unsigned quantity with no invalid value to represent.
|
||||
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.
|
||||
if (!hfs.checkSelf())
|
||||
{
|
||||
JLOG(hfs.getJournal().error()) << "wasm: host functions not clean before the run";
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
}
|
||||
|
||||
HostContext ctx{hfs};
|
||||
auto const run = rs::wasm_vm::run_escrow(
|
||||
ctx,
|
||||
rust::Slice<std::uint8_t const>(wasmCode.data(), wasmCode.size()),
|
||||
static_cast<std::uint64_t>(gasLimit),
|
||||
rust::Str(funcName.data(), funcName.size()));
|
||||
|
||||
auto const result = outcome(run);
|
||||
if (!result)
|
||||
{
|
||||
JLOG(hfs.getJournal().warn())
|
||||
<< "wasm: " << std::string_view(run.detail.data(), run.detail.size())
|
||||
<< ", 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});
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -23,6 +23,11 @@ set_target_properties(
|
||||
target_include_directories(xrpl_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl)
|
||||
|
||||
# Lets the wasm tests write their modules as WebAssembly text. Test-only by construction:
|
||||
# the assembler lives in a crate nothing in libxrpl or xrpld links (see crates/CMakeLists).
|
||||
target_link_libraries(xrpl_tests PRIVATE xrpl_wasm_testkit_cxxbridge)
|
||||
add_dependencies(xrpl_tests xrpl_crates)
|
||||
|
||||
# One source subdirectory per module. Network unit tests are currently not
|
||||
# supported on Windows.
|
||||
set(test_modules
|
||||
|
||||
312
src/tests/libxrpl/tx/wasm/HostCalls.cpp
Normal file
312
src/tests/libxrpl/tx/wasm/HostCalls.cpp
Normal file
@@ -0,0 +1,312 @@
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
using testing::Return;
|
||||
|
||||
// The code a host error crosses as. The guest sees it as the host function's return value,
|
||||
// so a soft failure is the contract's to interpret rather than the engine's to trap on.
|
||||
std::int32_t
|
||||
code(HostFunctionError error)
|
||||
{
|
||||
return hfErrorToInt(error);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// ldgr_index — no input, one scalar output
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
class LedgerSqnCall : public HostCallTest
|
||||
{
|
||||
protected:
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
|
||||
;; Four bytes is what the value needs. Returns what the host wrote, or its error code.
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $ldgr_index (i32.const 0) (i32.const 4)))
|
||||
(select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0))))
|
||||
|
||||
;; Two bytes is not enough for the value. Returns the host's code when memory is still
|
||||
;; zero, or 1 if anything was written into it - so a refused write is visibly a refusal
|
||||
;; and not a truncation.
|
||||
(func (export "into_two_bytes") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $ldgr_index (i32.const 0) (i32.const 2)))
|
||||
(select (local.get $n) (i32.const 1) (i32.eqz (i32.load (i32.const 0))))))
|
||||
)wat"};
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(LedgerSqnCall, SequenceReachesGuestAsFourLittleEndianBytes)
|
||||
{
|
||||
EXPECT_CALL(host_, getLedgerSqn()).WillOnce(Return(0x01020304u));
|
||||
|
||||
// Read back with `i32.load`, which is little-endian by the wasm spec — so the value
|
||||
// arriving intact is the byte order being right.
|
||||
EXPECT_EQ(hostAnswer(), 0x01020304);
|
||||
}
|
||||
|
||||
TEST_F(LedgerSqnCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host_, getLedgerSqn())
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::LedgerObjNotFound)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::LedgerObjNotFound));
|
||||
}
|
||||
|
||||
// The engine decides the fit, not the host: the host is never told the guest's capacity, it
|
||||
// reports the value's true length and the engine turns a length past the buffer into
|
||||
// `BufferTooSmall` — with nothing written.
|
||||
TEST_F(LedgerSqnCall, BufferTooSmallIsRefusedWholeNotTruncated)
|
||||
{
|
||||
EXPECT_CALL(host_, getLedgerSqn()).WillOnce(Return(0x01020304u));
|
||||
|
||||
EXPECT_EQ(hostAnswer("into_two_bytes"), code(HostFunctionError::BufferTooSmall));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// home_le_field — a scalar field code in, bytes out
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
class CurrentLedgerObjFieldCall : public HostCallTest
|
||||
{
|
||||
protected:
|
||||
// The field code the guest asks for. A real one, so the shim's `SField` lookup has
|
||||
// something to find.
|
||||
std::int32_t fieldCode_ = sfBalance.getCode();
|
||||
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $home_le_field (i32.const )wat"} +
|
||||
std::to_string(fieldCode_) + R"wat() (i32.const 0) (i32.const 32))))
|
||||
)wat";
|
||||
}
|
||||
};
|
||||
|
||||
// The shim turns the guest's `i32` into the `SField` the C++ interface takes; asserting on
|
||||
// the argument is what pins that translation rather than assuming it.
|
||||
TEST_F(CurrentLedgerObjFieldCall, FieldCodeBecomesSFieldHostIsAskedFor)
|
||||
{
|
||||
EXPECT_CALL(host_, getCurrentLedgerObjField(testing::Ref(sfBalance)))
|
||||
.WillOnce(Return(Bytes{1, 2, 3}));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 3) << "the length the host reported";
|
||||
}
|
||||
|
||||
TEST_F(CurrentLedgerObjFieldCall, UnknownFieldCodeIsRefusedWithoutAskingHost)
|
||||
{
|
||||
fieldCode_ = 0x7fff'0000; // a type nothing is registered under
|
||||
EXPECT_CALL(host_, getCurrentLedgerObjField).Times(0);
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::InvalidField));
|
||||
}
|
||||
|
||||
TEST_F(CurrentLedgerObjFieldCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host_, getCurrentLedgerObjField)
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::FieldNotFound)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::FieldNotFound));
|
||||
}
|
||||
|
||||
// The field cap bounds the status, not just the bytes: a host reporting a length past
|
||||
// `kMaxWasmDataLength` is too large whatever the guest's buffer was.
|
||||
TEST_F(CurrentLedgerObjFieldCall, FieldPastProtocolCapIsTooLarge)
|
||||
{
|
||||
EXPECT_CALL(host_, getCurrentLedgerObjField)
|
||||
.WillOnce(Return(Bytes(kMaxWasmDataLength + 1, 0xab)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::DataFieldTooLarge));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// sha512_half — bytes in and bytes out, the shape that needs the engine's output buffer
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
class Sha512HalfCall : public HostCallTest
|
||||
{
|
||||
protected:
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 64) "abc")
|
||||
|
||||
;; Hashes the three bytes at 64 into the 32 at 0, then returns the first four bytes of the
|
||||
;; digest so the answer is shown to have arrived, not just been counted.
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32)))
|
||||
(select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0))))
|
||||
|
||||
;; Reports the length the host gave, for the cases where the digest itself is not the point.
|
||||
(func (export "digest_length") (result i32)
|
||||
(call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32))))
|
||||
)wat"};
|
||||
}
|
||||
|
||||
// A digest whose first four bytes are distinctive, so the load below cannot pass by
|
||||
// accident.
|
||||
static Hash
|
||||
digest()
|
||||
{
|
||||
Hash value;
|
||||
value.begin()[0] = 0x0d;
|
||||
value.begin()[1] = 0x0c;
|
||||
value.begin()[2] = 0x0b;
|
||||
value.begin()[3] = 0x0a;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Both directions in one call: the guest's bytes reach the host borrowed from its memory, and
|
||||
// the answer comes back into the same memory through the engine's buffer.
|
||||
TEST_F(Sha512HalfCall, GuestBytesReachHostAndDigestComesBack)
|
||||
{
|
||||
EXPECT_CALL(host_, computeSha512HalfHash(BytesAre("abc"))).WillOnce(Return(digest()));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 0x0a0b0c0d) << "the digest's first four bytes, little-endian";
|
||||
}
|
||||
|
||||
TEST_F(Sha512HalfCall, DigestIsThirtyTwoBytes)
|
||||
{
|
||||
EXPECT_CALL(host_, computeSha512HalfHash).WillOnce(Return(digest()));
|
||||
|
||||
EXPECT_EQ(hostAnswer("digest_length"), 32);
|
||||
}
|
||||
|
||||
TEST_F(Sha512HalfCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host_, computeSha512HalfHash)
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::InvalidParams)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::InvalidParams));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// trace — two byte inputs and a flag, no output
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
class TraceCall : public HostCallTest
|
||||
{
|
||||
protected:
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "trace" (func $trace (param i32 i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "note")
|
||||
(data (i32.const 16) "\07\08")
|
||||
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const 16) (i32.const 2) (i32.const 1)))
|
||||
|
||||
(func (export "not_as_hex") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const 16) (i32.const 2) (i32.const 0))))
|
||||
)wat"};
|
||||
}
|
||||
};
|
||||
|
||||
// Two borrowed regions in one call, which is the shape a single-input helper could not
|
||||
// express — so this pins that both arrive intact, and the flag with them.
|
||||
TEST_F(TraceCall, MessageDataAndFlagAllArrive)
|
||||
{
|
||||
EXPECT_CALL(host_, trace(std::string_view("note"), BytesAre("\x07\x08"), true))
|
||||
.WillOnce(Return(0));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 0) << "a call with nothing to report answers 0";
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, HexFlagIsGuestsToChoose)
|
||||
{
|
||||
EXPECT_CALL(host_, trace(testing::_, testing::_, false)).WillOnce(Return(0));
|
||||
|
||||
EXPECT_EQ(hostAnswer("not_as_hex"), 0);
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host_, trace).WillOnce(Return(std::unexpected(HostFunctionError::InvalidParams)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::InvalidParams));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// trace_num — a string and an i64, the ABI's only 64-bit parameter
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
class TraceNumCall : public HostCallTest
|
||||
{
|
||||
protected:
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "trace_num" (func $trace_num (param i32 i32 i64) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "count")
|
||||
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $trace_num (i32.const 0) (i32.const 5) (i64.const -9223372036854775808))))
|
||||
)wat"};
|
||||
}
|
||||
};
|
||||
|
||||
// The extreme value on purpose: an `i64` that a truncating or sign-losing conversion anywhere
|
||||
// on the wire would visibly mangle.
|
||||
TEST_F(TraceNumCall, I64ArrivesWholeIncludingMostNegativeValue)
|
||||
{
|
||||
EXPECT_CALL(
|
||||
host_,
|
||||
traceNum(std::string_view("count"), std::numeric_limits<std::int64_t>::min()))
|
||||
.WillOnce(Return(0));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 0);
|
||||
}
|
||||
|
||||
TEST_F(TraceNumCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host_, traceNum)
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::IndexOutOfBounds)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), code(HostFunctionError::IndexOutOfBounds));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
87
src/tests/libxrpl/tx/wasm/MockHostFunctions.h
Normal file
87
src/tests/libxrpl/tx/wasm/MockHostFunctions.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A mock of the host the wasm engine calls back into.
|
||||
//
|
||||
// Only the methods the ABI currently declares are mocked, and that is deliberate: the ~60
|
||||
// others keep `HostFunctions`' own `std::unexpected(Unimplemented)`, so a contract reaching
|
||||
// for something the ABI has not declared yet fails the way production would. Add a
|
||||
// `MOCK_METHOD` here when the matching entry is added to `host_functions!`.
|
||||
//
|
||||
// Every mocked method defaults to what the base class would have done, for the same reason:
|
||||
// gmock's own default for `std::expected<T, E>` is a *successful* `T{}`, so an un-stubbed
|
||||
// call would answer `0` and a test could pass on an answer nobody chose. `checkSelf`
|
||||
// defaults to `true` because that is the base's answer and `runEscrowWasm` refuses a host
|
||||
// that reports itself dirty.
|
||||
class MockHostFunctions : public HostFunctions
|
||||
{
|
||||
public:
|
||||
explicit MockHostFunctions(beast::Journal journal) : HostFunctions(journal)
|
||||
{
|
||||
using testing::Return;
|
||||
auto const unimplemented = std::unexpected(HostFunctionError::Unimplemented);
|
||||
|
||||
ON_CALL(*this, checkSelf()).WillByDefault(Return(true));
|
||||
ON_CALL(*this, getLedgerSqn()).WillByDefault(Return(unimplemented));
|
||||
ON_CALL(*this, getCurrentLedgerObjField).WillByDefault(Return(unimplemented));
|
||||
ON_CALL(*this, computeSha512HalfHash).WillByDefault(Return(unimplemented));
|
||||
ON_CALL(*this, trace).WillByDefault(Return(unimplemented));
|
||||
ON_CALL(*this, traceNum).WillByDefault(Return(unimplemented));
|
||||
}
|
||||
|
||||
MOCK_METHOD(bool, checkSelf, (), (const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getLedgerSqn,
|
||||
(),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getCurrentLedgerObjField,
|
||||
(SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Hash, HostFunctionError>),
|
||||
computeSha512HalfHash,
|
||||
(Slice const& data),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
trace,
|
||||
(std::string_view const& msg, Slice const& data, bool asHex),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
traceNum,
|
||||
(std::string_view const& msg, std::int64_t number),
|
||||
(const, override));
|
||||
};
|
||||
|
||||
// Matches a `Slice` (or anything with `data()`/`size()`) against the bytes of a string, so
|
||||
// an expectation can say *what* the guest asked the host to work on.
|
||||
MATCHER_P(BytesAre, expected, "")
|
||||
{
|
||||
return std::string_view(reinterpret_cast<char const*>(arg.data()), arg.size()) ==
|
||||
std::string_view(expected);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
145
src/tests/libxrpl/tx/wasm/WasmFixture.h
Normal file
145
src/tests/libxrpl/tx/wasm/WasmFixture.h
Normal file
@@ -0,0 +1,145 @@
|
||||
#pragma once
|
||||
|
||||
#include <tx/wasm/MockHostFunctions.h>
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <xrpl_wasm_testkit_cxxbridge/lib.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Keeps what a run logged. The host's default journal is a null sink, which would let a
|
||||
// swallowed condition pass a test that only checks the TER.
|
||||
class CapturingSink : public beast::Journal::Sink
|
||||
{
|
||||
std::string text_;
|
||||
|
||||
public:
|
||||
CapturingSink() : Sink(beast::Severity::Warning, false)
|
||||
{
|
||||
}
|
||||
|
||||
void
|
||||
write(beast::Severity level, std::string const& text) override
|
||||
{
|
||||
writeAlways(level, text);
|
||||
}
|
||||
|
||||
void
|
||||
writeAlways(beast::Severity, std::string const& text) override
|
||||
{
|
||||
text_ += text;
|
||||
text_ += '\n';
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string const&
|
||||
text() const
|
||||
{
|
||||
return text_;
|
||||
}
|
||||
};
|
||||
|
||||
// Base for every wasm test: a mocked host whose log is captured, and one way into the engine.
|
||||
//
|
||||
// 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.
|
||||
class WasmTest : public testing::Test
|
||||
{
|
||||
protected:
|
||||
// Enough for every module here to run to completion; a test about budgets passes its own.
|
||||
static constexpr std::int64_t kAmpleGas = 100'000;
|
||||
|
||||
CapturingSink sink_;
|
||||
|
||||
// Strict: a host call no test asked for is a failure, not a warning. These modules import
|
||||
// exactly what they mean to exercise, so an unplanned call means the engine reached for
|
||||
// something on its own — which is the kind of surprise a test suite exists to catch.
|
||||
testing::StrictMock<MockHostFunctions> host_{beast::Journal{sink_}};
|
||||
|
||||
WasmTest()
|
||||
{
|
||||
// `runEscrowWasm` asks every run whether the host is clean, so under a strict mock
|
||||
// every test would have to say so. Declared once here, and any number of times
|
||||
// (including none, for the runs refused before the engine is reached). A test that
|
||||
// cares says otherwise and its own expectation wins.
|
||||
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()};
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
run(std::string_view wat,
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return runEscrowWasm(assemble(wat), host_, gas, entryPoint);
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runBytes(
|
||||
Bytes const& wasm,
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return runEscrowWasm(wasm, host_, gas, entryPoint);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string const&
|
||||
logged() const
|
||||
{
|
||||
return sink_.text();
|
||||
}
|
||||
};
|
||||
|
||||
// Base for the per-host-function fixtures. Each derives, supplies the module that exercises
|
||||
// its own import, and runs it through `callHost()` — so a test says only what the host was
|
||||
// asked and what came back.
|
||||
class HostCallTest : public WasmTest
|
||||
{
|
||||
protected:
|
||||
// The module under test. One import, one `escrow_finish` that calls it.
|
||||
[[nodiscard]] virtual std::string
|
||||
wat() const = 0;
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
callHost(std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return run(wat(), kAmpleGas, entryPoint);
|
||||
}
|
||||
|
||||
// The contract's return value, which for these modules is what the host answered — or
|
||||
// its negative error code. Fails the test if the run did not complete.
|
||||
std::int32_t
|
||||
hostAnswer(std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
auto const outcome = callHost(entryPoint);
|
||||
if (!outcome)
|
||||
{
|
||||
ADD_FAILURE() << "the run did not complete: " << transToken(outcome.error().ter)
|
||||
<< "; logged: " << logged();
|
||||
return 0;
|
||||
}
|
||||
return outcome->result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
277
src/tests/libxrpl/tx/wasm/WasmVM.cpp
Normal file
277
src/tests/libxrpl/tx/wasm/WasmVM.cpp
Normal file
@@ -0,0 +1,277 @@
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
// One module with an export per way a run can end. Kept together because these are properties
|
||||
// of the engine rather than of any host function: the only import is there so the
|
||||
// out-of-gas and no-memory cases have a host call to fail in.
|
||||
constexpr std::string_view kEngineWat = 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) (i32.const 5))
|
||||
|
||||
(func (export "calls_the_host") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4)))
|
||||
|
||||
(func (export "traps") (result i32) unreachable)
|
||||
|
||||
(func (export "never_returns") (result i32) (loop (br 0)) (i32.const 0))
|
||||
|
||||
(func (export "wrong_signature") (param i32) (result i32) (local.get 0))
|
||||
|
||||
(global (export "not_a_function") i32 (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
// The same host call with no memory exported, so the engine has nothing to resolve a byte
|
||||
// region against.
|
||||
constexpr std::string_view kNoMemoryWat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4))))
|
||||
)wat";
|
||||
|
||||
} // namespace
|
||||
|
||||
class WasmVMTest : public WasmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(WasmVMTest, ContractReturnValueReachesCaller)
|
||||
{
|
||||
auto const outcome = run(kEngineWat);
|
||||
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, 5);
|
||||
EXPECT_GT(outcome->cost, 0) << "running any instruction costs gas";
|
||||
EXPECT_LT(outcome->cost, kAmpleGas);
|
||||
}
|
||||
|
||||
TEST_F(WasmVMTest, GuestTrapIsChargedAsContractFault)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "traps");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING);
|
||||
ASSERT_TRUE(outcome.error().cost.has_value());
|
||||
EXPECT_GT(*outcome.error().cost, 0);
|
||||
}
|
||||
|
||||
TEST_F(WasmVMTest, NonTerminatingContractSpendsWholeBudget)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "never_returns");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS);
|
||||
ASSERT_TRUE(outcome.error().cost.has_value());
|
||||
EXPECT_EQ(*outcome.error().cost, kAmpleGas);
|
||||
}
|
||||
|
||||
// A budget too small to reach the first host charge is still out of gas, whatever the engine
|
||||
// can account for by then.
|
||||
TEST_F(WasmVMTest, BudgetTooSmallToRunIsOutOfGas)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, 1, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS);
|
||||
EXPECT_TRUE(outcome.error().cost.has_value());
|
||||
}
|
||||
|
||||
// No gas is not a small budget, it is a malformed transaction — refused before the engine is
|
||||
// asked to run anything.
|
||||
TEST_F(WasmVMTest, NoGasIsRefusedAsMalformedRatherThanRun)
|
||||
{
|
||||
for (std::int64_t const gas : {std::int64_t{0}, std::int64_t{-1}})
|
||||
{
|
||||
auto const outcome = run(kEngineWat, gas);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << "gas: " << gas;
|
||||
EXPECT_EQ(outcome.error().ter, temBAD_AMOUNT) << "gas: " << gas;
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << "gas: " << gas;
|
||||
}
|
||||
}
|
||||
|
||||
// A host call needs a memory to resolve its byte regions against, and the export is not
|
||||
// optional for a contract that makes one.
|
||||
TEST_F(WasmVMTest, HostCallWithNoExportedMemoryFails)
|
||||
{
|
||||
auto const outcome = run(kNoMemoryWat);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING);
|
||||
EXPECT_TRUE(outcome.error().cost.has_value());
|
||||
}
|
||||
|
||||
// Preflight is meant to refuse these with `temBAD_WASM`; reaching apply means the screening
|
||||
// did not happen, which is the node's fault and not the transaction's.
|
||||
TEST_F(WasmVMTest, UnrunnableModuleIsNodeSideFault)
|
||||
{
|
||||
struct
|
||||
{
|
||||
char const* what;
|
||||
Bytes code;
|
||||
std::string_view entryPoint;
|
||||
} const cases[] = {
|
||||
{.what = "not wasm at all",
|
||||
.code = Bytes{0, 1, 2, 3},
|
||||
.entryPoint = escrowFunctionName},
|
||||
{.what = "empty", .code = Bytes{}, .entryPoint = escrowFunctionName},
|
||||
{.what = "no such export",
|
||||
.code = assemble(kEngineWat),
|
||||
.entryPoint = "no_such_export"},
|
||||
{.what = "export is not a function",
|
||||
.code = assemble(kEngineWat),
|
||||
.entryPoint = "not_a_function"},
|
||||
{.what = "export takes a parameter",
|
||||
.code = assemble(kEngineWat),
|
||||
.entryPoint = "wrong_signature"},
|
||||
};
|
||||
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
auto const outcome = runBytes(c.code, kAmpleGas, c.entryPoint);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << c.what;
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL) << c.what;
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << c.what;
|
||||
}
|
||||
}
|
||||
|
||||
// wasmi's `wat` feature would make `Module::new` accept text as readily as binary, which would
|
||||
// put an assembler on the consensus path and make a module's validity a build flag. The
|
||||
// engine turns that feature off; this is the guest-side proof, using the very text the rest
|
||||
// of this file assembles.
|
||||
TEST_F(WasmVMTest, TextFormatModuleIsRejected)
|
||||
{
|
||||
Bytes const text{kEngineWat.begin(), kEngineWat.end()};
|
||||
|
||||
auto const outcome = runBytes(text);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
}
|
||||
|
||||
// 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.
|
||||
TEST_F(WasmVMTest, DirtyHostIsRefusedBeforeContractRuns)
|
||||
{
|
||||
EXPECT_CALL(host_, checkSelf()).WillOnce(testing::Return(false));
|
||||
|
||||
auto const outcome = run(kEngineWat);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
EXPECT_FALSE(outcome.error().cost.has_value());
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("not clean"));
|
||||
}
|
||||
|
||||
// A soft host error is the contract's to interpret, so its code has to cross the boundary
|
||||
// unchanged: the engine must not renumber it, clamp it, or turn it into a failure of its own.
|
||||
//
|
||||
// Over the whole of `HostFunctionError` rather than a sample, because the C++ and Rust error
|
||||
// enums are two hand-maintained lists of the same wire numbers and they have already drifted
|
||||
// once — C++ spells -11 `OutOfTransferLimit` where the Rust ABI spells it `Decoding`. This is
|
||||
// the test that notices if either side renumbers.
|
||||
//
|
||||
// The two exclusions are the codes the Rust engine treats as host-fatal, which stop the run
|
||||
// instead of reaching the guest: -1 (its `Internal`, which C++ spells `Unimplemented`) and
|
||||
// -14 `NoMemExported`.
|
||||
TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged)
|
||||
{
|
||||
constexpr HostFunctionError kSoftErrors[] = {
|
||||
HostFunctionError::FieldNotFound,
|
||||
HostFunctionError::BufferTooSmall,
|
||||
HostFunctionError::NoArray,
|
||||
HostFunctionError::NotLeafField,
|
||||
HostFunctionError::LocatorMalformed,
|
||||
HostFunctionError::SlotOutRange,
|
||||
HostFunctionError::SlotsFull,
|
||||
HostFunctionError::EmptySlot,
|
||||
HostFunctionError::LedgerObjNotFound,
|
||||
HostFunctionError::OutOfTransferLimit,
|
||||
HostFunctionError::DataFieldTooLarge,
|
||||
HostFunctionError::PointerOutOfBounds,
|
||||
HostFunctionError::InvalidParams,
|
||||
HostFunctionError::InvalidAccount,
|
||||
HostFunctionError::InvalidField,
|
||||
HostFunctionError::IndexOutOfBounds,
|
||||
HostFunctionError::FloatInputMalformed,
|
||||
HostFunctionError::FloatComputationError,
|
||||
};
|
||||
|
||||
auto refused = HostFunctionError::FieldNotFound;
|
||||
EXPECT_CALL(host_, getLedgerSqn())
|
||||
.WillRepeatedly([&refused]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
return std::unexpected(refused);
|
||||
});
|
||||
|
||||
for (auto const error : kSoftErrors)
|
||||
{
|
||||
refused = error;
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_TRUE(outcome.has_value()) << hfErrorToInt(error) << " stopped the run";
|
||||
EXPECT_EQ(outcome->result, hfErrorToInt(error));
|
||||
}
|
||||
}
|
||||
|
||||
// The counterpart: a fatal code stops the run rather than reaching the contract, so a host
|
||||
// that cannot serve a call cannot be second-guessed by the contract.
|
||||
TEST_F(WasmVMTest, FatalHostErrorStopsRun)
|
||||
{
|
||||
auto refused = HostFunctionError::Unimplemented;
|
||||
EXPECT_CALL(host_, getLedgerSqn())
|
||||
.WillRepeatedly([&refused]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
return std::unexpected(refused);
|
||||
});
|
||||
|
||||
for (auto const error : {HostFunctionError::Unimplemented, HostFunctionError::NoMemExported})
|
||||
{
|
||||
refused = error;
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << hfErrorToInt(error) << " reached the contract";
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the bridge's C++ half: an exception must not reach the Rust frames that called
|
||||
// the host, and must not take the node with it.
|
||||
TEST_F(WasmVMTest, ThrowingHostFunctionBecomesInternal)
|
||||
{
|
||||
EXPECT_CALL(host_, getLedgerSqn()).WillOnce([]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
Throw<std::runtime_error>("the ledger came apart");
|
||||
});
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << "a node-side fault charges nothing";
|
||||
// Caught is not swallowed: the condition has to be recorded, and the line has to name the
|
||||
// call it came out of.
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("the ledger came apart"));
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("getLedgerSqn"));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
Reference in New Issue
Block a user