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

@@ -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>