From 42904ac8987fe539cfec72d93ac56c0f4d83d059 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Tue, 15 Sep 2026 18:47:48 -0400 Subject: [PATCH] Cover the contract marshalling shapes end to end Three shapes the contract host functions add to the e2e table, each one a convention a guest and a host could agree about separately and still disagree about in practice. - A typed value written by the guest and read back without its type byte, answered out of the cache by a write in the same run, for an account whose data object is not on the ledger at all. - A scalar index between two regions. Writing element 0 and element 1 and reading them apart is what shows an index taken from the wrong place; reading back the only element written would not. - The builder index, which is the only contract host state that outlives one call, together with the TER crossing as bytes in a region rather than as the call's result. - An `STJson` object the guest serialized, a different parser from the one a stored value goes through. `ContractVmTest` takes the host rather than building one, because what these tests assert is what the contract left behind, which only the caller's `ContractHost` can be asked about afterwards. --- .../libxrpl/tx/wasm/e2e/ContractData.cpp | 133 ++++++++++++++++++ .../libxrpl/tx/wasm/e2e/ContractEmit.cpp | 104 ++++++++++++++ .../libxrpl/tx/wasm/e2e/ContractEvent.cpp | 68 +++++++++ .../libxrpl/tx/wasm/fixtures/ContractVmTest.h | 33 +++++ 4 files changed, 338 insertions(+) create mode 100644 src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp create mode 100644 src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp create mode 100644 src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp create mode 100644 src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h diff --git a/src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp b/src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp new file mode 100644 index 0000000000..9287d296e8 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp @@ -0,0 +1,133 @@ +#include +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +// A contract writes a typed value and reads it back, through the real engine and the real +// host over a real ledger. +// +// Two conventions meet here that no other layer can check together. The guest writes a value +// as a type byte followed by a serialization, and reads it back **without** the type byte — +// so a shim that agreed with the host about the bytes but disagreed about the type byte would +// pass `host_calls` and `host_functions` separately and fail only here. The cache is the +// other: the read is answered by a write in the same run, from an account whose data object +// is not on the ledger at all. +struct ContractDataE2e : ContractVmTest +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + // Writes `u16(1234)` under "value_u16", reads it back, and returns the two bytes that + // came back — big-endian, so `0x04d2` reads as 1234 after the swap the load does. + [[nodiscard]] std::string + wat() const + { + auto const id = alice.id(); + std::string account; + for (auto const byte : id) + account += std::format("\\{:02x}", byte); + + return std::format( + R"wat( +(module + (import "host_lib" "set_data_object_field" + (func $set_data_object_field (param i32 i32 i32 i32 i32 i32) (result i32))) + (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) "value_u16") + (data (i32.const 48) "\01\04\d2") + + (func (export "escrow_finish") (result i32) + (local $n i32) + + ;; Store 1234 as an STI_UINT16: the type byte, then the value. + (local.set $n (call $set_data_object_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 9) (i32.const 48) (i32.const 3))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + + ;; Read it back. What comes back is the serialization alone, so two bytes, not three. + (local.set $n (call $get_data_object_field + (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 9) (i32.const 64) (i32.const 32))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + (if (i32.ne (local.get $n) (i32.const 2)) (then (return (i32.const -100)))) + + (i32.load16_u (i32.const 64)))) +)wat", + account); + } +}; + +TEST_F(ContractDataE2e, AValueIsWrittenWithItsTypeAndReadBackWithoutIt) +{ + auto const contractHost = + makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + + auto const outcome = run(contractHost, wat()); + ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter); + + // 1234 is 0x04d2. It was stored big-endian, so the little-endian load reads 0xd204. + EXPECT_EQ(outcome->result, 0xd204); + + auto const& [modified, data] = contractHost.context().result.dataMap.at(alice.id()); + EXPECT_TRUE(modified); + auto const expected = STJson{STJson::Map{{"value_u16", u16(1234)}}}; + EXPECT_EQ(data.toBlob(), expected.toBlob()) << "what the guest wrote is what the host holds"; +} + +// The other shape the data calls have, and the one a guest and a host can most easily get +// backwards: a scalar index sitting between two regions. Writing element 1 and reading +// element 1 is not enough — writing 0 and 1 and reading them apart is. +TEST_F(ContractDataE2e, AnIndexBetweenTheRegionsSelectsTheElement) +{ + auto const id = alice.id(); + std::string account; + for (auto const byte : id) + account += std::format("\\{:02x}", byte); + + auto const kWat = std::format( + R"wat( +(module + (import "host_lib" "set_data_array_element_field" + (func $set (param i32 i32 i32 i32 i32 i32 i32) (result i32))) + (import "host_lib" "get_data_array_element_field" + (func $get (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 48) "\10\0a") + (data (i32.const 52) "\10\14") + + (func (export "escrow_finish") (result i32) + (local $n i32) + (local.set $n (call $set (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) + (i32.const 0) (i32.const 48) (i32.const 2))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + (local.set $n (call $set (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) + (i32.const 1) (i32.const 52) (i32.const 2))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + + ;; Read element 1, which holds 20. Element 0 holds 10, so a swapped index shows. + (local.set $n (call $get (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 6) + (i32.const 1) (i32.const 64) (i32.const 32))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + (i32.load8_u (i32.const 64)))) +)wat", + account); + + auto const contractHost = + makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + + auto const outcome = run(contractHost, kWat); + ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter); + EXPECT_EQ(outcome->result, 20) << "element 1, not element 0"; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp b/src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp new file mode 100644 index 0000000000..8c5337a504 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp @@ -0,0 +1,104 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +// A contract builds a payment and emits it, end to end. +// +// Two things meet here that no other layer sees together. The builder index is host state +// that outlives one call — `build_txn` answers it and two later calls name it — and the TER +// crosses as **bytes in a region** rather than as the call's result, because half the TER +// codes are negative and a negative result is a host error. +struct ContractEmitE2e : ContractVmTest +{ + Account const alice = fund("alice"); + Account const contract = fund("contract", XRP(2000)); + Account const carol = fund("carol"); + + // Builds a payment of 192 drops to carol, emits it, and returns the TER that was written + // to the output region. + [[nodiscard]] std::string + wat() const + { + auto const amount = WasmLedger::toBytes(STAmount{XRP(192)}); + auto const destination = accountField(carol.id()); + + auto escape = [](Bytes const& bytes) { + std::string out; + for (auto const byte : bytes) + out += std::format("\\{:02x}", byte); + return out; + }; + + return std::format( + R"wat( +(module + (import "host_lib" "build_txn" (func $build_txn (param i32) (result i32))) + (import "host_lib" "add_txn_field" + (func $add_txn_field (param i32 i32 i32 i32) (result i32))) + (import "host_lib" "emit_built_txn" + (func $emit_built_txn (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 32) "{}") + + (func (export "escrow_finish") (result i32) + (local $txn i32) + (local $n i32) + + ;; The index this answers is the only host state that outlives a call. + (local.set $txn (call $build_txn (i32.const {}))) + (if (i32.lt_s (local.get $txn) (i32.const 0)) (then (return (local.get $txn)))) + + (local.set $n (call $add_txn_field + (local.get $txn) (i32.const {}) (i32.const 0) (i32.const {}))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + + (local.set $n (call $add_txn_field + (local.get $txn) (i32.const {}) (i32.const 32) (i32.const {}))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + + ;; The TER is written here, not returned: a negative one would read as a host error. + (local.set $n + (call $emit_built_txn (local.get $txn) (i32.const 64) (i32.const 4))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then (return (local.get $n)))) + (if (i32.ne (local.get $n) (i32.const 4)) (then (return (i32.const -100)))) + + (i32.load (i32.const 64)))) +)wat", + escape(amount), + escape(destination), + static_cast(ttPAYMENT), + sfAmount.getCode(), + amount.size(), + sfDestination.getCode(), + destination.size()); + } +}; + +TEST_F(ContractEmitE2e, AContractBuildsAPaymentAndEmitsIt) +{ + auto const contractHost = + makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + + auto const outcome = run(contractHost, wat()); + ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter); + EXPECT_EQ(outcome->result, TERtoInt(tesSUCCESS)) + << "the TER the guest read out of its own memory"; + + ASSERT_EQ(contractHost.context().result.emittedTxns.size(), 1U); + auto const& emitted = contractHost.context().result.emittedTxns.front(); + EXPECT_EQ(emitted->getAccountID(sfAccount), contract.id()); + EXPECT_EQ(emitted->getAccountID(sfDestination), carol.id()); + EXPECT_EQ(emitted->getFieldAmount(sfAmount), STAmount{XRP(192)}); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp b/src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp new file mode 100644 index 0000000000..4acd24500d --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +// A contract emits an event it serialized itself. +// +// This is the second wire format a guest writes rather than reads, and it goes through a +// different parser from the one a stored value goes through: a whole `STJson` object, keys +// and all, instead of one typed field. A guest and a host that agreed about values could +// still disagree about this, which is why it is its own case. +struct ContractEventE2e : ContractVmTest +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + static STJson + event() + { + return STJson{STJson::Map{{"count", u32(32)}, {"kind", u8(7)}}}; + } + + [[nodiscard]] std::string + wat() const + { + auto const blob = event().toBlob(); + std::string escaped; + for (auto const byte : blob) + escaped += std::format("\\{:02x}", static_cast(byte)); + + 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) "{}") + + (func (export "escrow_finish") (result i32) + (call $emit_event (i32.const 0) (i32.const 11) (i32.const 32) (i32.const {})))) +)wat", + escaped, + blob.size()); + } +}; + +TEST_F(ContractEventE2e, AGuestSerializedEventReachesTheEventMap) +{ + auto const contractHost = + makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + + auto const outcome = run(contractHost, wat()); + ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter); + EXPECT_EQ(outcome->result, 0); + + auto const& events = contractHost.context().result.eventMap; + ASSERT_EQ(events.size(), 1U); + EXPECT_EQ(events.at("transferred").toBlob(), event().toBlob()) + << "what the guest serialized is what the host parsed"; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h b/src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h new file mode 100644 index 0000000000..4dc5c74d2c --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +// End to end for a contract: a WAT guest through the real VM, the real `HostContext`, the +// real `ContractHostFunctionsImpl`, and a real ledger. +// +// The host is passed in rather than built here, because what these tests are about is +// usually what the contract left behind — its data cache, its event map, the transactions it +// queued — which only the caller's `ContractHost` can be asked about afterwards. +struct ContractVmTest : ContractHostFixture +{ + std::expected + run(ContractHost const& contractHost, + std::string_view wat, + std::int64_t gas = kAmpleGas, + std::string_view entryPoint = escrowFunctionName) + { + return runWat(*contractHost, wat, gas, entryPoint); + } +}; + +} // namespace xrpl::test