From fed023680a7af2d3e5617a89364ea75420f77cc6 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Tue, 15 Sep 2026 18:44:24 -0400 Subject: [PATCH] Pin the wire contract of every contract host function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the `host_calls/` layer: a mock host behind the real VM, with each module hand-writing its own imports, literal regions and literal lengths, so what is tested is the wire rather than whatever an SDK sends. The order of two same-shaped arguments is the thing worth asserting here, because nothing downstream would notice them swapped: a nested read takes the outer key first, and an array read takes the key before the index while the host takes them the other way round. Each is asserted against a value the other could not be. What the shim refuses before the host is reached, now stated: an account region that is not twenty bytes, a key that is not UTF-8, a negative index, a transaction type that does not fit in sixteen bits, a field code the protocol does not name, bytes that are not a transaction, bytes that are not an object, and an empty value region. `SliceIs` is added beside `BytesAre` for data that is not text. `BytesAre` compares against a string and so stops at the first NUL, which most serialized fields contain — an amount matched as "@" and passed. --- .../tx/wasm/fixtures/ContractCallFixture.h | 64 ++++++++++ .../tx/wasm/fixtures/MockHostFunctions.h | 8 ++ .../tx/wasm/host_calls/AddTxnField.cpp | 81 +++++++++++++ .../libxrpl/tx/wasm/host_calls/BuildTxn.cpp | 72 ++++++++++++ .../libxrpl/tx/wasm/host_calls/EmitEvent.cpp | 84 ++++++++++++++ .../libxrpl/tx/wasm/host_calls/EmitTxn.cpp | 109 ++++++++++++++++++ .../tx/wasm/host_calls/FunctionParam.cpp | 80 +++++++++++++ .../host_calls/GetDataArrayElementField.cpp | 76 ++++++++++++ .../GetDataNestedArrayElementField.cpp | 76 ++++++++++++ .../host_calls/GetDataNestedObjectField.cpp | 64 ++++++++++ .../tx/wasm/host_calls/GetDataObjectField.cpp | 85 ++++++++++++++ .../tx/wasm/host_calls/InstanceParam.cpp | 79 +++++++++++++ .../host_calls/SetDataArrayElementField.cpp | 73 ++++++++++++ .../SetDataNestedArrayElementField.cpp | 68 +++++++++++ .../host_calls/SetDataNestedObjectField.cpp | 68 +++++++++++ 15 files changed, 1087 insertions(+) create mode 100644 src/tests/libxrpl/tx/wasm/fixtures/ContractCallFixture.h create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/AddTxnField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/BuildTxn.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/EmitEvent.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/EmitTxn.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/FunctionParam.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/GetDataArrayElementField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedArrayElementField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedObjectField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/GetDataObjectField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/InstanceParam.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/SetDataArrayElementField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedArrayElementField.cpp create mode 100644 src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedObjectField.cpp diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractCallFixture.h b/src/tests/libxrpl/tx/wasm/fixtures/ContractCallFixture.h new file mode 100644 index 0000000000..c64329090c --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractCallFixture.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include +#include + +// What a contract's `host_calls` tests share: the guest-side bytes each call carries. +// +// These modules hand-write the ABI — literal regions, literal lengths — so they hold the +// host to the wire rather than to whatever an SDK happens to send. + +namespace xrpl::test { + +struct ContractCallTest : HostCallTest +{ + // A 20-byte account the guest holds as bytes. Distinctive at both ends, so a region read + // at the wrong offset or the wrong length cannot match it. + static AccountID + account() + { + AccountID id; + id.begin()[0] = 0xae; + id.begin()[19] = 0xea; + return id; + } + + // Bytes as a WAT data-segment string. + static std::string + escaped(Bytes const& bytes) + { + std::string out; + for (auto const byte : bytes) + out += std::format("\\{:02x}", byte); + return out; + } + + static std::string + escapedAccount() + { + auto const id = account(); + return escaped(Bytes{id.begin(), id.end()}); + } + + // The bytes a guest writes for a `set_data_*` value: its one-byte type, then its + // serialization. + static Bytes + valueBytes(STJson::Value const& value) + { + Bytes wire{static_cast(value->getSType())}; + Serializer s; + value->add(s); + wire.insert(wire.end(), s.peekData().begin(), s.peekData().end()); + return wire; + } +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/fixtures/MockHostFunctions.h b/src/tests/libxrpl/tx/wasm/fixtures/MockHostFunctions.h index a97fccf673..8a6f79f176 100644 --- a/src/tests/libxrpl/tx/wasm/fixtures/MockHostFunctions.h +++ b/src/tests/libxrpl/tx/wasm/fixtures/MockHostFunctions.h @@ -545,6 +545,14 @@ MATCHER_P(BytesAre, expected, "") std::string_view{expected}; } +// Matches a `Slice` against exact bytes. `BytesAre` compares against a string and so stops +// at the first NUL, which most serialized fields contain. +// NOLINTNEXTLINE(readability-identifier-naming) +MATCHER_P(SliceIs, expected, "") +{ + return Bytes{arg.data(), arg.data() + arg.size()} == expected; +} + // Matches an `AccountID` against another, so an expectation can name *whose* data a // contract asked the host for. // NOLINTNEXTLINE(readability-identifier-naming) diff --git a/src/tests/libxrpl/tx/wasm/host_calls/AddTxnField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/AddTxnField.cpp new file mode 100644 index 0000000000..25b0b7ee9d --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/AddTxnField.cpp @@ -0,0 +1,81 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Ref; +using testing::Return; + +// add_txn_field — a field code the shim translates into an `SField`, plus the bytes that +// field is to be built from. +struct AddTxnFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "add_txn_field" + (func $add_txn_field (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\40\00\00\00\00\00\00\c0") + + (func (export "escrow_finish") (result i32) + (call $add_txn_field (i32.const 1) (i32.const {}) (i32.const 0) (i32.const 8))) + + ;; A field code the protocol does not name: a 16-bit integer field nothing declares. + (func (export "unknown_field") (result i32) + (call $add_txn_field (i32.const 1) (i32.const {}) (i32.const 0) (i32.const 8))) + + (func (export "negative_index") (result i32) + (call $add_txn_field (i32.const -1) (i32.const {}) (i32.const 0) (i32.const 8)))) +)wat", + sfAmount.getCode(), + (static_cast(STI_UINT16) << 16) | 9999, + sfAmount.getCode()); + } +}; + +TEST_F(AddTxnFieldCall, TheFieldCodeBecomesAnSFieldAndTheBytesArrive) +{ + auto const amount = Bytes{0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0}; + EXPECT_CALL(host, addTxnField(1U, Ref(sfAmount), SliceIs(amount))).WillOnce(Return(0)); + + EXPECT_EQ(hostAnswer(), 0); +} + +// A code the protocol does not name has no `SField` to translate to, so the host is never +// asked which field the contract meant. +TEST_F(AddTxnFieldCall, AnUnknownFieldCodeIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, addTxnField).Times(0); + + EXPECT_EQ(hostAnswer("unknown_field"), hfErrorToInt(HostFunctionError::InvalidField)); +} + +TEST_F(AddTxnFieldCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, addTxnField).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +TEST_F(AddTxnFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, addTxnField) + .WillOnce(Return(std::unexpected(HostFunctionError::FieldNotFound))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::FieldNotFound)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/BuildTxn.cpp b/src/tests/libxrpl/tx/wasm/host_calls/BuildTxn.cpp new file mode 100644 index 0000000000..912824dce5 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/BuildTxn.cpp @@ -0,0 +1,72 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// build_txn — one scalar in, one out. The narrowest call in the ABI, and the only one whose +// argument has to be narrowed: a transaction type is sixteen bits inside wasm's thirty-two. +struct BuildTxnCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "build_txn" (func $build_txn (param i32) (result i32))) + (memory (export "memory") 1) + + (func (export "escrow_finish") (result i32) + (call $build_txn (i32.const 0))) + + ;; A type that does not fit in sixteen bits, which no transaction type can be. + (func (export "type_too_large") (result i32) + (call $build_txn (i32.const 65536))) + + (func (export "negative_type") (result i32) + (call $build_txn (i32.const -1)))) +)wat"}; + } +}; + +TEST_F(BuildTxnCall, TheTypeReachesTheHostAndTheIndexComesBack) +{ + EXPECT_CALL(host, buildTxn(static_cast(ttPAYMENT))).WillOnce(Return(3)); + + EXPECT_EQ(hostAnswer(), 3); +} + +// Narrowing an out-of-range type would ask the host to build something else entirely, so it +// is refused instead. +TEST_F(BuildTxnCall, ATypeThatDoesNotFitIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, buildTxn).Times(0); + + EXPECT_EQ(hostAnswer("type_too_large"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(BuildTxnCall, ANegativeTypeIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, buildTxn).Times(0); + + EXPECT_EQ(hostAnswer("negative_type"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(BuildTxnCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, buildTxn) + .WillOnce(Return(std::unexpected(HostFunctionError::SubmitTxnFailure))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::SubmitTxnFailure)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/EmitEvent.cpp b/src/tests/libxrpl/tx/wasm/host_calls/EmitEvent.cpp new file mode 100644 index 0000000000..83095655bb --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/EmitEvent.cpp @@ -0,0 +1,84 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// emit_event — a name and a serialized object in, a scalar out. The second of the two wire +// formats a guest writes itself, and a different parser from the one a value goes through. +struct EmitEventCall : ContractCallTest +{ + static STJson + event() + { + return STJson{STJson::Map{ + {"count", + std::static_pointer_cast(std::make_shared(sfSequence, 32U))}}}; + } + + [[nodiscard]] std::string + wat() const override + { + auto const blob = event().toBlob(); + return std::format( + R"wat( +(module + (import "host_lib" "emit_event" (func $emit_event (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "transferred") + (data (i32.const 32) "{}") + (data (i32.const 2048) "not an object") + + (func (export "escrow_finish") (result i32) + (call $emit_event (i32.const 0) (i32.const 11) (i32.const 32) (i32.const {}))) + + (func (export "not_an_object") (result i32) + (call $emit_event (i32.const 0) (i32.const 11) (i32.const 2048) (i32.const 13))) + + (func (export "empty_data") (result i32) + (call $emit_event (i32.const 0) (i32.const 11) (i32.const 32) (i32.const 0)))) +)wat", + escaped(Bytes{blob.begin(), blob.end()}), + blob.size()); + } +}; + +TEST_F(EmitEventCall, TheNameAndTheDecodedObjectReachTheHost) +{ + EXPECT_CALL(host, emitEvent(BytesAre("transferred"), EventJsonEq(event()))).WillOnce(Return(0)); + + EXPECT_EQ(hostAnswer(), 0); +} + +TEST_F(EmitEventCall, BytesThatAreNotAnObjectAreRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, emitEvent).Times(0); + + EXPECT_EQ(hostAnswer("not_an_object"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(EmitEventCall, AnEmptyRegionIsNotAnObject) +{ + EXPECT_CALL(host, emitEvent).Times(0); + + EXPECT_EQ(hostAnswer("empty_data"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(EmitEventCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, emitEvent).WillOnce(Return(std::unexpected(HostFunctionError::InvalidState))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidState)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/EmitTxn.cpp b/src/tests/libxrpl/tx/wasm/host_calls/EmitTxn.cpp new file mode 100644 index 0000000000..6ded42c797 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/EmitTxn.cpp @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// emit_txn — a serialized transaction in, a TER out through the guest's buffer. +// +// The shim deserializes, so the host is handed a transaction rather than bytes: a guest that +// writes something that is not one never reaches it. +struct EmitTxnCall : ContractCallTest +{ + // A transaction as a contract would have serialized it. + static STTx + transaction() + { + return STTx{ttPAYMENT, [](STObject& obj) { + obj.setAccountID(sfAccount, account()); + obj.setAccountID(sfDestination, account()); + obj.setFieldAmount(sfAmount, STAmount{192}); + obj.setFieldAmount(sfFee, STAmount{0}); + obj.setFieldU32(sfSequence, 1); + obj.setFieldU32(sfFlags, tfInnerBatchTxn); + obj.setFieldVL(sfSigningPubKey, Blob{}); + }}; + } + + static Bytes + serialized() + { + auto const s = transaction().getSerializer(); + return Bytes{s.peekData().begin(), s.peekData().end()}; + } + + [[nodiscard]] std::string + wat() const override + { + auto const bytes = serialized(); + return std::format( + R"wat( +(module + (import "host_lib" "emit_txn" (func $emit_txn (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 2048) "not a transaction") + + ;; Emits the serialized transaction and returns the TER it wrote. + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n + (call $emit_txn (i32.const 0) (i32.const {}) (i32.const 3072) (i32.const 4))) + (select (local.get $n) (i32.load (i32.const 3072)) (i32.lt_s (local.get $n) (i32.const 0)))) + + (func (export "not_a_transaction") (result i32) + (call $emit_txn (i32.const 2048) (i32.const 17) (i32.const 3072) (i32.const 4)))) +)wat", + escaped(bytes), + bytes.size()); + } +}; + +TEST_F(EmitTxnCall, TheGuestsBytesReachTheHostAsATransaction) +{ + EXPECT_CALL(host, emitTxn(StTxIdIs(transaction().getTransactionID()))) + .WillOnce(Return(TERtoInt(tesSUCCESS))); + + EXPECT_EQ(hostAnswer(), TERtoInt(tesSUCCESS)); +} + +// The TER travels in the buffer because it may be negative, and a negative return is a host +// error. This is the case that would otherwise have stopped the run. +TEST_F(EmitTxnCall, ANegativeTerArrivesIntact) +{ + EXPECT_CALL(host, emitTxn).WillOnce(Return(TERtoInt(tefPAST_SEQ))); + + EXPECT_EQ(hostAnswer(), TERtoInt(tefPAST_SEQ)); +} + +TEST_F(EmitTxnCall, BytesThatAreNotATransactionAreRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, emitTxn).Times(0); + + EXPECT_EQ(hostAnswer("not_a_transaction"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(EmitTxnCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, emitTxn) + .WillOnce(Return(std::unexpected(HostFunctionError::SubmitTxnFailure))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::SubmitTxnFailure)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/FunctionParam.cpp b/src/tests/libxrpl/tx/wasm/host_calls/FunctionParam.cpp new file mode 100644 index 0000000000..7b33e5b1b9 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/FunctionParam.cpp @@ -0,0 +1,80 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// function_param — the same shape as `instance_param`, over the call's own parameters. +// Which of the two tables the host is asked about is the whole of the difference. +struct FunctionParamCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "function_param" + (func $function_param (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; Asks for parameter 2 as an STI_UINT32 (2) and returns the four bytes that landed. + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n + (call $function_param (i32.const 2) (i32.const 2) (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, for the cases where the value itself is not the point. + (func (export "value_length") (result i32) + (call $function_param (i32.const 2) (i32.const 2) (i32.const 0) (i32.const 32))) + + ;; A negative index, which the shim refuses rather than casting. + (func (export "negative_index") (result i32) + (call $function_param (i32.const -1) (i32.const 2) (i32.const 0) (i32.const 32)))) +)wat"}; + } +}; + +TEST_F(FunctionParamCall, TheIndexAndTypeReachTheHostAndTheBytesComeBack) +{ + EXPECT_CALL(host, functionParam(2U, STI_UINT32)) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + EXPECT_CALL(host, instanceParam).Times(0); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d) << "the value's bytes, read back little-endian"; +} + +TEST_F(FunctionParamCall, TheAnswerIsTheValuesLength) +{ + EXPECT_CALL(host, functionParam).WillOnce(Return(Bytes{1, 2, 3, 4})); + + EXPECT_EQ(hostAnswer("value_length"), 4); +} + +// A negative index is out of range, not a very large one: casting it would ask the host +// about a parameter no contract could have meant. +TEST_F(FunctionParamCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, functionParam).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(FunctionParamCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, functionParam) + .WillOnce(Return(std::unexpected(HostFunctionError::IndexOutOfBounds))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/GetDataArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/GetDataArrayElementField.cpp new file mode 100644 index 0000000000..ceeb4616e2 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/GetDataArrayElementField.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// get_data_array_element_field — the shape with a scalar *between* two regions: account, +// key, index, out. +// +// The key comes before the index on the wire, and the host takes them the other way round. +// A test that only checked both arrived would pass with them swapped, so this asserts each +// against a value the other could not be. +struct GetDataArrayElementFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "get_data_array_element_field" + (func $get_data_array_element_field (param i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "amount") + + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $get_data_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) (i32.const 7) + (i32.const 64) (i32.const 32))) + (select (local.get $n) (i32.load (i32.const 64)) (i32.lt_s (local.get $n) (i32.const 0)))) + + ;; A negative element index, which the shim refuses rather than casting to a huge one. + (func (export "negative_index") (result i32) + (call $get_data_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) (i32.const -1) + (i32.const 64) (i32.const 32)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(GetDataArrayElementFieldCall, TheKeyIsTheRegionAndTheIndexIsTheScalar) +{ + EXPECT_CALL(host, getDataArrayElementField(AccountIs(account()), 7U, BytesAre("amount"))) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d); +} + +TEST_F(GetDataArrayElementFieldCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, getDataArrayElementField).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +TEST_F(GetDataArrayElementFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getDataArrayElementField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidState))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidState)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedArrayElementField.cpp new file mode 100644 index 0000000000..a7121c0b14 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedArrayElementField.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// get_data_nested_array_element_field — the widest shape in the ABI: two string keys with a +// scalar index between them, then the output region. +struct GetDataNestedArrayElementFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "get_data_nested_array_element_field" + (func $get_data_nested_array_element_field + (param i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "items") + (data (i32.const 48) "price") + + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $get_data_nested_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const 7) + (i32.const 48) (i32.const 5) (i32.const 64) (i32.const 32))) + (select (local.get $n) (i32.load (i32.const 64)) (i32.lt_s (local.get $n) (i32.const 0)))) + + (func (export "negative_index") (result i32) + (call $get_data_nested_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const -1) + (i32.const 48) (i32.const 5) (i32.const 64) (i32.const 32)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(GetDataNestedArrayElementFieldCall, EachRegionAndTheIndexArriveInOrder) +{ + EXPECT_CALL( + host, + getDataNestedArrayElementField( + AccountIs(account()), BytesAre("items"), 7U, BytesAre("price"))) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d); +} + +TEST_F(GetDataNestedArrayElementFieldCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, getDataNestedArrayElementField).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +TEST_F(GetDataNestedArrayElementFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getDataNestedArrayElementField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidField))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidField)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedObjectField.cpp new file mode 100644 index 0000000000..98c4f52b68 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/GetDataNestedObjectField.cpp @@ -0,0 +1,64 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// get_data_nested_object_field — two string keys in, bytes out. +// +// The outer key comes first. Two regions of the same kind are the easiest pair to hand over +// backwards, and nothing downstream would notice, so the order is asserted with keys that +// could not be mistaken for one another. +struct GetDataNestedObjectFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "get_data_nested_object_field" + (func $get_data_nested_object_field (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "stats") + (data (i32.const 48) "score") + + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $get_data_nested_object_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const 48) (i32.const 5) + (i32.const 64) (i32.const 32))) + (select (local.get $n) (i32.load (i32.const 64)) (i32.lt_s (local.get $n) (i32.const 0))))) +)wat", + escapedAccount()); + } +}; + +TEST_F(GetDataNestedObjectFieldCall, TheOuterKeyIsTheFirstRegion) +{ + EXPECT_CALL( + host, getDataNestedObjectField(AccountIs(account()), BytesAre("stats"), BytesAre("score"))) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d); +} + +TEST_F(GetDataNestedObjectFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getDataNestedObjectField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidField))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidField)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/GetDataObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/GetDataObjectField.cpp new file mode 100644 index 0000000000..3be02d7c12 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/GetDataObjectField.cpp @@ -0,0 +1,85 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// get_data_object_field — an account region and a string key in, bytes out. +// +// The key is declared `&str`, so the engine checks it is UTF-8 before the host sees it: a +// region that is not becomes `InvalidParams` without a call. +struct GetDataObjectFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "get_data_object_field" + (func $get_data_object_field (param i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "count") + (data (i32.const 48) "\ff\fe") + + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $get_data_object_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const 64) (i32.const 32))) + (select (local.get $n) (i32.load (i32.const 64)) (i32.lt_s (local.get $n) (i32.const 0)))) + + ;; A nineteen-byte account, which is not an account id. + (func (export "short_account") (result i32) + (call $get_data_object_field + (i32.const 0) (i32.const 19) (i32.const 32) (i32.const 5) (i32.const 64) (i32.const 32))) + + ;; A key region that is not UTF-8. + (func (export "invalid_key") (result i32) + (call $get_data_object_field + (i32.const 0) (i32.const 20) (i32.const 48) (i32.const 2) (i32.const 64) (i32.const 32)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(GetDataObjectFieldCall, TheAccountAndKeyReachTheHostAndTheValueComesBack) +{ + EXPECT_CALL(host, getDataObjectField(AccountIs(account()), BytesAre("count"))) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d); +} + +TEST_F(GetDataObjectFieldCall, AnAccountRegionOfTheWrongLengthIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, getDataObjectField).Times(0); + + EXPECT_EQ(hostAnswer("short_account"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(GetDataObjectFieldCall, AKeyThatIsNotTextIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, getDataObjectField).Times(0); + + EXPECT_EQ(hostAnswer("invalid_key"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(GetDataObjectFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getDataObjectField) + .WillOnce(Return(std::unexpected(HostFunctionError::LedgerObjNotFound))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::LedgerObjNotFound)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/InstanceParam.cpp b/src/tests/libxrpl/tx/wasm/host_calls/InstanceParam.cpp new file mode 100644 index 0000000000..3bdd708f60 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/InstanceParam.cpp @@ -0,0 +1,79 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// instance_param — two scalars in, bytes out. The scalars are wasm's own `i32`, so nothing +// is marshalled on the way in and the index reaches the host as it was written. +struct InstanceParamCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::string{R"wat( +(module + (import "host_lib" "instance_param" + (func $instance_param (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; Asks for parameter 2 as an STI_UINT32 (2) and returns the four bytes that landed. + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n + (call $instance_param (i32.const 2) (i32.const 2) (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, for the cases where the value itself is not the point. + (func (export "value_length") (result i32) + (call $instance_param (i32.const 2) (i32.const 2) (i32.const 0) (i32.const 32))) + + ;; A negative index, which the shim refuses rather than casting. + (func (export "negative_index") (result i32) + (call $instance_param (i32.const -1) (i32.const 2) (i32.const 0) (i32.const 32)))) +)wat"}; + } +}; + +TEST_F(InstanceParamCall, TheIndexAndTypeReachTheHostAndTheBytesComeBack) +{ + EXPECT_CALL(host, instanceParam(2U, STI_UINT32)) + .WillOnce(Return(Bytes{0x0d, 0x0c, 0x0b, 0x0a})); + + EXPECT_EQ(hostAnswer(), 0x0a0b0c0d) << "the value's bytes, read back little-endian"; +} + +TEST_F(InstanceParamCall, TheAnswerIsTheValuesLength) +{ + EXPECT_CALL(host, instanceParam).WillOnce(Return(Bytes{1, 2, 3, 4})); + + EXPECT_EQ(hostAnswer("value_length"), 4); +} + +// A negative index is out of range, not a very large one: casting it would ask the host +// about a parameter no contract could have meant. +TEST_F(InstanceParamCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, instanceParam).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::InvalidParams)); +} + +TEST_F(InstanceParamCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, instanceParam) + .WillOnce(Return(std::unexpected(HostFunctionError::IndexOutOfBounds))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/SetDataArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/SetDataArrayElementField.cpp new file mode 100644 index 0000000000..139038d355 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/SetDataArrayElementField.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// set_data_array_element_field — a key, a scalar index between the regions, and a value. +struct SetDataArrayElementFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "set_data_array_element_field" + (func $set_data_array_element_field (param i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "amount") + (data (i32.const 64) "\10\2a") + + (func (export "escrow_finish") (result i32) + (call $set_data_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) (i32.const 7) + (i32.const 64) (i32.const 2))) + + (func (export "negative_index") (result i32) + (call $set_data_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) (i32.const -1) + (i32.const 64) (i32.const 2)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(SetDataArrayElementFieldCall, TheKeyTheIndexAndTheValueAllArrive) +{ + EXPECT_CALL( + host, + setDataArrayElementField( + AccountIs(account()), 7U, BytesAre("amount"), JsonValueIs(STI_UINT8, Bytes{0x2a}))) + .WillOnce(Return(0)); + + EXPECT_EQ(hostAnswer(), 0); +} + +TEST_F(SetDataArrayElementFieldCall, ANegativeIndexIsRefusedWithoutAskingTheHost) +{ + EXPECT_CALL(host, setDataArrayElementField).Times(0); + + EXPECT_EQ(hostAnswer("negative_index"), hfErrorToInt(HostFunctionError::IndexOutOfBounds)); +} + +TEST_F(SetDataArrayElementFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, setDataArrayElementField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidState))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidState)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedArrayElementField.cpp new file mode 100644 index 0000000000..bb4e5d4654 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedArrayElementField.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// set_data_nested_array_element_field — every kind of argument the ABI has in one call: an +// account, two string keys, a scalar index between them, and a typed value. +struct SetDataNestedArrayElementFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "set_data_nested_array_element_field" + (func $set_data_nested_array_element_field + (param i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "items") + (data (i32.const 48) "price") + (data (i32.const 64) "\10\2a") + + (func (export "escrow_finish") (result i32) + (call $set_data_nested_array_element_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const 7) + (i32.const 48) (i32.const 5) (i32.const 64) (i32.const 2)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(SetDataNestedArrayElementFieldCall, EveryArgumentArrivesInItsOwnPlace) +{ + EXPECT_CALL( + host, + setDataNestedArrayElementField( + AccountIs(account()), + BytesAre("items"), + 7U, + BytesAre("price"), + JsonValueIs(STI_UINT8, Bytes{0x2a}))) + .WillOnce(Return(0)); + + EXPECT_EQ(hostAnswer(), 0); +} + +TEST_F(SetDataNestedArrayElementFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, setDataNestedArrayElementField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidState))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidState)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedObjectField.cpp new file mode 100644 index 0000000000..72199ac322 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_calls/SetDataNestedObjectField.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +using testing::Return; + +// set_data_nested_object_field — two string keys and a typed value. +// +// The value's type byte decides what the shim builds, and the outer key comes first, so +// both are asserted against something the other could not be. +struct SetDataNestedObjectFieldCall : ContractCallTest +{ + [[nodiscard]] std::string + wat() const override + { + return std::format( + R"wat( +(module + (import "host_lib" "set_data_nested_object_field" + (func $set_data_nested_object_field (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "stats") + (data (i32.const 48) "score") + (data (i32.const 64) "\02\00\00\27\0f") + + (func (export "escrow_finish") (result i32) + (call $set_data_nested_object_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 5) (i32.const 48) (i32.const 5) + (i32.const 64) (i32.const 5)))) +)wat", + escapedAccount()); + } +}; + +TEST_F(SetDataNestedObjectFieldCall, TheOuterKeyComesFirstAndTheValueIsDecoded) +{ + EXPECT_CALL( + host, + setDataNestedObjectField( + AccountIs(account()), + BytesAre("stats"), + BytesAre("score"), + JsonValueIs(STI_UINT32, (Bytes{0x00, 0x00, 0x27, 0x0f})))) + .WillOnce(Return(0)); + + EXPECT_EQ(hostAnswer(), 0); +} + +TEST_F(SetDataNestedObjectFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, setDataNestedObjectField) + .WillOnce(Return(std::unexpected(HostFunctionError::InvalidState))); + + EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidState)); +} + +} // namespace xrpl::test