mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 22:30:57 +00:00
Implement ffi and host functions bindings
This commit is contained in:
@@ -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