diff --git a/include/xrpl/tx/wasm/README.md b/include/xrpl/tx/wasm/README.md index 4b99510705..7be22a6feb 100644 --- a/include/xrpl/tx/wasm/README.md +++ b/include/xrpl/tx/wasm/README.md @@ -18,10 +18,11 @@ bridge. `ApplyContext&`. Bodies are split across `HostFuncImpl*.cpp` by category. - **`HostContext.h`** — the bridge's C++ half: an ABI-shaped, `noexcept` view of `HostFunctions` that the engine calls back into. Nothing may unwind into Rust, so every - method routes through one `guarded()`. + method routes through `guarded()`. - **`WasmCommon.h`** — the shared vocabulary: `HostFunctionError` (the codes a contract - sees), `Bytes`, `FieldLocator`, `WasmTER`, and `adjustWasmEndianess`, which is where the - boundary's byte order is decided. + sees), `Bytes`, `FieldLocator`, `WasmTER`, `adjustWasmEndianess`, which is where the + boundary's byte order is decided, and `guarded()`, the one catch every crossing of the + bridge's C++ half goes through. ## Host functions diff --git a/include/xrpl/tx/wasm/WasmCommon.h b/include/xrpl/tx/wasm/WasmCommon.h index d2bb44284c..fa651bef10 100644 --- a/include/xrpl/tx/wasm/WasmCommon.h +++ b/include/xrpl/tx/wasm/WasmCommon.h @@ -1,13 +1,17 @@ #pragma once +#include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -146,4 +150,28 @@ hfErrorToInt(HostFunctionError e) return static_cast(e); } +template +std::invoke_result_t +guarded( + beast::Journal journal, + std::invoke_result_t onThrow, + Body&& body, + std::source_location const location = std::source_location::current()) noexcept +{ + try + { + return body(); + } + catch (std::exception const& e) + { + JLOG(journal.error()) << "wasm: " << location.function_name() << " threw: " << e.what(); + } + catch (...) + { + JLOG(journal.error()) << "wasm: " << location.function_name() << " threw"; + } + + return onThrow; +} + } // namespace xrpl diff --git a/include/xrpl/tx/wasm/WasmVM.h b/include/xrpl/tx/wasm/WasmVM.h index d52e63b44c..99161b93af 100644 --- a/include/xrpl/tx/wasm/WasmVM.h +++ b/include/xrpl/tx/wasm/WasmVM.h @@ -22,15 +22,12 @@ std::string_view inline constexpr escrowFunctionName = "escrow_finish"; // 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 runEscrowWasm( Bytes const& wasmCode, HostFunctions& hfs, std::int64_t gasLimit, - std::string_view funcName = escrowFunctionName); + std::string_view funcName = escrowFunctionName) noexcept; // Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's // first instruction. Compiles the module and reads its imports and exports; runs @@ -44,12 +41,10 @@ runEscrowWasm( // engine cannot run, so it is refused before it can reach the ledger. // `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the // module, and a defect here is not evidence that the transaction is malformed. -// -// Does not throw. NotTEC preflightEscrowWasm( Bytes const& wasmCode, beast::Journal j, - std::string_view funcName = escrowFunctionName); + std::string_view funcName = escrowFunctionName) noexcept; } // namespace xrpl diff --git a/src/libxrpl/tx/wasm/HostContext.cpp b/src/libxrpl/tx/wasm/HostContext.cpp index d6742ada5a..355d99434a 100644 --- a/src/libxrpl/tx/wasm/HostContext.cpp +++ b/src/libxrpl/tx/wasm/HostContext.cpp @@ -1,9 +1,7 @@ #include -#include #include #include -#include #include #include #include @@ -11,16 +9,15 @@ #include #include #include -#include -#include #include 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`. +// What a host call answers when it could not be served at all: every method below hands it +// to `guarded` as the answer for a body that throws. 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 @@ -28,39 +25,6 @@ namespace { // 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 -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. @@ -96,12 +60,11 @@ HostContext::HostContext(HostFunctions& hostFunctions) : hostFunctions_(hostFunc std::int32_t HostContext::getLedgerSqn(rust::Slice out) const noexcept { - return guarded(hostFunctions_.getJournal(), [&] { + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { 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); }); } @@ -110,7 +73,7 @@ std::int32_t HostContext::getCurrentLedgerObjField(std::int32_t field, rust::Slice out) const noexcept { - return guarded(hostFunctions_.getJournal(), [&] { + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { auto const& knownSFields = SField::getKnownCodeToField(); auto const it = knownSFields.find(field); if (it == knownSFields.end()) @@ -128,8 +91,8 @@ std::int32_t HostContext::sha512Half(rust::Slice data, rust::Slice out) const noexcept { - return guarded(hostFunctions_.getJournal(), [&] { - auto const digest = hostFunctions_.computeSha512HalfHash(Slice(data.data(), data.size())); + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { + auto const digest = hostFunctions_.computeSha512HalfHash(Slice{data.data(), data.size()}); if (!digest) return hfErrorToInt(digest.error()); @@ -140,9 +103,9 @@ HostContext::sha512Half(rust::Slice data, rust::Slice data, bool asHex) const noexcept { - return guarded(hostFunctions_.getJournal(), [&] { + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { auto const status = hostFunctions_.trace( - std::string_view(msg.data(), msg.size()), Slice(data.data(), data.size()), asHex); + std::string_view{msg.data(), msg.size()}, Slice{data.data(), data.size()}, asHex); if (!status) return hfErrorToInt(status.error()); @@ -153,9 +116,9 @@ HostContext::trace(rust::Str msg, rust::Slice data, bool asH std::int32_t HostContext::traceNum(rust::Str msg, std::int64_t number) const noexcept { - return guarded(hostFunctions_.getJournal(), [&] { + return guarded(hostFunctions_.getJournal(), kHostInternal, [&] { auto const status = - hostFunctions_.traceNum(std::string_view(msg.data(), msg.size()), number); + hostFunctions_.traceNum(std::string_view{msg.data(), msg.size()}, number); if (!status) return hfErrorToInt(status.error()); diff --git a/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp b/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp index ce2de57a0b..5ab0a74c3d 100644 --- a/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp +++ b/src/libxrpl/tx/wasm/HostFuncImplGetter.cpp @@ -125,8 +125,8 @@ getAnyFieldData(FieldValue const& variantObj) return Bytes((*u)->begin(), (*u)->end()); // Unreachable: the variant only holds the two alternatives above. If not, it is an - // xrpld bug, and `HostContext::guarded` turns the throw into the engine's fatal - // `Internal` -> tecINTERNAL. + // xrpld bug, and `guarded` turns the throw into the engine's fatal `Internal` -> + // tecINTERNAL. Throw("field value variant holds neither alternative"); // LCOV_EXCL_LINE } diff --git a/src/libxrpl/tx/wasm/WasmVM.cpp b/src/libxrpl/tx/wasm/WasmVM.cpp index 8e29fcd41a..690e41fbdb 100644 --- a/src/libxrpl/tx/wasm/WasmVM.cpp +++ b/src/libxrpl/tx/wasm/WasmVM.cpp @@ -10,11 +10,9 @@ #include #include -#include #include #include #include -#include namespace xrpl { @@ -45,7 +43,7 @@ outcome(rs::wasm_vm::RunResult const& run) // 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}); + 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. @@ -57,7 +55,7 @@ outcome(rs::wasm_vm::RunResult const& run) // refused here. It is a deterministic property of the code either way, and one // this node's own conduct had no part in. case RunStatus::Instantiate: - return std::unexpected(WasmTER{.ter = tecFAILED_PROCESSING, .cost = cost}); + return std::unexpected{WasmTER{.ter = tecFAILED_PROCESSING, .cost = cost}}; // A module that will not compile, or does not expose the entry point, should have // been refused at preflight with `temBAD_WASM`: screening decides both from the @@ -71,40 +69,9 @@ outcome(rs::wasm_vm::RunResult const& run) // 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}); + return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; } - std::unreachable(); -} - -// 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. `HostContext`'s methods are `noexcept` rather than leaving this to cxx because -// cxx's own `trycatch` catches only `std::exception`, and only for `Result` returns. -template -std::invoke_result_t -guarded(beast::Journal j, std::invoke_result_t 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; + UNREACHABLE("Unexpected RunStatus value"); } // A screening verdict as a TER. @@ -137,7 +104,7 @@ verdict(CheckStatus status) case CheckStatus::Panic: return telFAILED_PROCESSING; } - std::unreachable(); + UNREACHABLE("Unexpected CheckStatus value"); } } // namespace @@ -153,9 +120,9 @@ runEscrowWasm( // 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}); + return std::unexpected{WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt}}; - auto const nodeSideFault = std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}); + auto const nodeSideFault = std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}}; return guarded(hfs.getJournal(), nodeSideFault, [&]() -> std::expected { // The host caches the current ledger object, the slot table and the @@ -170,15 +137,15 @@ runEscrowWasm( HostContext ctx{hfs}; auto const run = rs::wasm_vm::run_escrow( ctx, - rust::Slice(wasmCode.data(), wasmCode.size()), + rust::Slice{wasmCode.data(), wasmCode.size()}, static_cast(gasLimit), - rust::Str(funcName.data(), funcName.size())); + 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()) + << "wasm: " << std::string_view{run.detail.data(), run.detail.size()} << ", ter: " << transToken(result.error().ter); } return result; @@ -190,14 +157,14 @@ preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view fu { return guarded(j, NotTEC{telFAILED_PROCESSING}, [&]() { auto const checked = rs::wasm_vm::check_escrow( - rust::Slice(wasmCode.data(), wasmCode.size()), - rust::Str(funcName.data(), funcName.size())); + rust::Slice{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()) + << std::string_view{checked.detail.data(), checked.detail.size()} << ", ter: " << transToken(ter); } return ter; diff --git a/src/tests/libxrpl/tx/wasm/HostCalls.cpp b/src/tests/libxrpl/tx/wasm/HostCalls.cpp deleted file mode 100644 index eba4a5dabd..0000000000 --- a/src/tests/libxrpl/tx/wasm/HostCalls.cpp +++ /dev/null @@ -1,310 +0,0 @@ -#include -#include -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include - -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::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 diff --git a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h index e8324ec8f1..fb087a7208 100644 --- a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h +++ b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h @@ -21,26 +21,10 @@ namespace xrpl::test { // 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` 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 +struct MockHostFunctions : 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)); @@ -80,8 +64,8 @@ public: // an expectation can say *what* the guest asked the host to work on. MATCHER_P(BytesAre, expected, "") { - return std::string_view(reinterpret_cast(arg.data()), arg.size()) == - std::string_view(expected); + return std::string_view{reinterpret_cast(arg.data()), arg.size()} == + std::string_view{expected}; } } // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/Preflight.cpp b/src/tests/libxrpl/tx/wasm/Preflight.cpp index 0c90d446ac..21bca78d9b 100644 --- a/src/tests/libxrpl/tx/wasm/Preflight.cpp +++ b/src/tests/libxrpl/tx/wasm/Preflight.cpp @@ -27,27 +27,26 @@ constexpr std::string_view kRunnableWat = R"wat( // `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 +struct PreflightTest : testing::Test { -protected: - CapturingSink sink_; + CaptureSink sink{beast::Severity::Warning}; NotTEC preflight(std::string_view wat, std::string_view funcName = escrowFunctionName) { - return preflightEscrowWasm(assembleWat(wat), beast::Journal{sink_}, funcName); + 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); + return preflightEscrowWasm(wasm, beast::Journal{sink}, funcName); } - [[nodiscard]] std::string const& + [[nodiscard]] std::string logged() const { - return sink_.text(); + return sink.messages(); } }; @@ -208,7 +207,7 @@ TEST_F(PreflightTest, ScreeningAgreesWithARun) // 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 host{beast::Journal{sink_}}; + testing::StrictMock host{beast::Journal{sink}}; EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true)); EXPECT_CALL(host, getLedgerSqn()).WillRepeatedly(testing::Return(7u)); diff --git a/src/tests/libxrpl/tx/wasm/WasmFixture.h b/src/tests/libxrpl/tx/wasm/WasmFixture.h index 96c638b263..c0acda2f40 100644 --- a/src/tests/libxrpl/tx/wasm/WasmFixture.h +++ b/src/tests/libxrpl/tx/wasm/WasmFixture.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -16,37 +17,6 @@ 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_; - } -}; - // Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test that // holds it. // @@ -55,7 +25,7 @@ public: inline Bytes assembleWat(std::string_view wat) { - auto const wasm = rs::wasm_testkit::compile_wat(rust::Str(wat.data(), wat.size())); + auto const wasm = rs::wasm_testkit::compile_wat(rust::Str{wat.data(), wat.size()}); return Bytes{wasm.begin(), wasm.end()}; } @@ -66,18 +36,19 @@ assembleWat(std::string_view wat) // 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 +struct WasmTest : 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_; + // 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. + CaptureSink sink{beast::Severity::Warning}; // 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 host_{beast::Journal{sink_}}; + testing::StrictMock host{beast::Journal{sink}}; WasmTest() { @@ -85,7 +56,7 @@ protected: // 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)); + EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true)); } static Bytes @@ -99,7 +70,7 @@ protected: std::int64_t gas = kAmpleGas, std::string_view entryPoint = escrowFunctionName) { - return runEscrowWasm(assemble(wat), host_, gas, entryPoint); + return runEscrowWasm(assemble(wat), host, gas, entryPoint); } std::expected @@ -108,22 +79,21 @@ protected: std::int64_t gas = kAmpleGas, std::string_view entryPoint = escrowFunctionName) { - return runEscrowWasm(wasm, host_, gas, entryPoint); + return runEscrowWasm(wasm, host, gas, entryPoint); } - [[nodiscard]] std::string const& + [[nodiscard]] std::string logged() const { - return sink_.text(); + return sink.messages(); } }; // 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 +struct HostCallTest : WasmTest { -protected: // The module under test. One import, one `escrow_finish` that calls it. [[nodiscard]] virtual std::string wat() const = 0; diff --git a/src/tests/libxrpl/tx/wasm/WasmVM.cpp b/src/tests/libxrpl/tx/wasm/WasmVM.cpp index 4eb5e23a99..556bab639e 100644 --- a/src/tests/libxrpl/tx/wasm/WasmVM.cpp +++ b/src/tests/libxrpl/tx/wasm/WasmVM.cpp @@ -99,7 +99,7 @@ TEST_F(WasmVMTest, BudgetTooSmallToRunIsOutOfGas) // asked to run anything. TEST_F(WasmVMTest, NoGasIsRefusedAsMalformedRatherThanRun) { - for (std::int64_t const gas : {std::int64_t{0}, std::int64_t{-1}}) + for (auto const gas : {std::int64_t{0}, std::int64_t{-1}}) { auto const outcome = run(kEngineWat, gas); @@ -126,13 +126,13 @@ TEST_F(WasmVMTest, HostCallWithNoExportedMemoryFails) TEST_F(WasmVMTest, ModuleThatWillNotInstantiateIsChargedToTheContract) { // 129 pages, not exported, so nothing outside the module declares it. - constexpr std::string_view wat = R"wat( + static constexpr std::string_view wat = R"wat( (module (memory 129) (func (export "escrow_finish") (result i32) (i32.const 0))) )wat"; - EXPECT_EQ(preflightEscrowWasm(assembleWat(wat), beast::Journal{sink_}), tesSUCCESS) + EXPECT_EQ(preflightEscrowWasm(assembleWat(wat), beast::Journal{sink}), tesSUCCESS) << "screening cannot see an unexported memory"; auto const outcome = run(wat); @@ -147,7 +147,7 @@ TEST_F(WasmVMTest, ModuleThatWillNotInstantiateIsChargedToTheContract) // have screened. TEST_F(WasmVMTest, TrappingStartSectionIsChargedToTheContract) { - constexpr std::string_view wat = R"wat( + static constexpr std::string_view wat = R"wat( (module (memory (export "memory") 1) (func $init (unreachable)) @@ -167,21 +167,26 @@ TEST_F(WasmVMTest, TrappingStartSectionIsChargedToTheContract) // did not happen, which is the node's fault and not the transaction's. TEST_F(WasmVMTest, UnrunnableModuleIsNodeSideFault) { - struct + struct Case { 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"}, + }; + std::array const cases = { + Case{ + .what = "not wasm at all", .code = Bytes{0, 1, 2, 3}, .entryPoint = escrowFunctionName}, + Case{.what = "empty", .code = Bytes{}, .entryPoint = escrowFunctionName}, + Case{ + .what = "no such export", .code = assemble(kEngineWat), .entryPoint = "no_such_export"}, + Case{ + .what = "export is not a function", + .code = assemble(kEngineWat), + .entryPoint = "not_a_function"}, + Case{ + .what = "export takes a parameter", + .code = assemble(kEngineWat), + .entryPoint = "wrong_signature"}, }; for (auto const& c : cases) @@ -213,7 +218,7 @@ TEST_F(WasmVMTest, TextFormatModuleIsRejected) // contract's state. TEST_F(WasmVMTest, DirtyHostIsRefusedBeforeContractRuns) { - EXPECT_CALL(host_, checkSelf()).WillOnce(testing::Return(false)); + EXPECT_CALL(host, checkSelf()).WillOnce(testing::Return(false)); auto const outcome = run(kEngineWat); @@ -236,7 +241,7 @@ TEST_F(WasmVMTest, DirtyHostIsRefusedBeforeContractRuns) // -14 `NoMemExported`. TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged) { - constexpr HostFunctionError kSoftErrors[] = { + static constexpr HostFunctionError kSoftErrors[] = { HostFunctionError::FieldNotFound, HostFunctionError::BufferTooSmall, HostFunctionError::NoArray, @@ -258,7 +263,7 @@ TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged) }; auto refused = HostFunctionError::FieldNotFound; - EXPECT_CALL(host_, getLedgerSqn()) + EXPECT_CALL(host, getLedgerSqn()) .WillRepeatedly([&refused]() -> std::expected { return std::unexpected(refused); }); @@ -279,7 +284,7 @@ TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged) TEST_F(WasmVMTest, FatalHostErrorStopsRun) { auto refused = HostFunctionError::Unimplemented; - EXPECT_CALL(host_, getLedgerSqn()) + EXPECT_CALL(host, getLedgerSqn()) .WillRepeatedly([&refused]() -> std::expected { return std::unexpected(refused); }); @@ -298,7 +303,7 @@ TEST_F(WasmVMTest, FatalHostErrorStopsRun) // the host, and must not take the node with it. TEST_F(WasmVMTest, ThrowingHostFunctionBecomesInternal) { - EXPECT_CALL(host_, getLedgerSqn()) + EXPECT_CALL(host, getLedgerSqn()) .WillOnce([]() -> std::expected { Throw("the ledger came apart"); }); diff --git a/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp new file mode 100644 index 0000000000..143c20fa96 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/CurrentLedgerObjField.cpp @@ -0,0 +1,74 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// home_le_field — a scalar field code in, bytes out. +struct CurrentLedgerObjFieldCall : HostCallTest +{ + // 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(), hfErrorToInt(HostFunctionError::InvalidField)); +} + +TEST_F(CurrentLedgerObjFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getCurrentLedgerObjField) + .WillOnce(Return(std::unexpected(HostFunctionError::FieldNotFound))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(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(), hfErrorToInt(HostFunctionError::DataFieldTooLarge)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp b/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp new file mode 100644 index 0000000000..d4cec43616 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp @@ -0,0 +1,69 @@ +#include + +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// ldgr_index — no input, one scalar output. +struct LedgerSqnCall : HostCallTest +{ + [[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(), hfErrorToInt(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"), hfErrorToInt(HostFunctionError::BufferTooSmall)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp b/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp new file mode 100644 index 0000000000..30af80d0f6 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp @@ -0,0 +1,78 @@ +#include +#include + +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// sha512_half — bytes in and bytes out, the shape that needs the engine's output buffer. +struct Sha512HalfCall : HostCallTest +{ + [[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(), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp b/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp new file mode 100644 index 0000000000..55ac767ad1 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// trace — two byte inputs and a flag, no output. +struct TraceCall : HostCallTest +{ + [[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(), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/TraceNum.cpp b/src/tests/libxrpl/tx/wasm/host_calls/TraceNum.cpp new file mode 100644 index 0000000000..1e49095ff8 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/TraceNum.cpp @@ -0,0 +1,53 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// trace_num — a string and an i64, the ABI's only 64-bit parameter. +struct TraceNumCall : HostCallTest +{ + [[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::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(), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +} // namespace xrpl::test