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.
This commit is contained in:
Mayukha Vadari
2026-09-15 18:47:48 -04:00
parent fed023680a
commit 42904ac898
4 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/protocol/TER.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractVmTest.h>
#include <format>
#include <string>
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

View File

@@ -0,0 +1,104 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractVmTest.h>
#include <format>
#include <string>
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<int>(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

View File

@@ -0,0 +1,68 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/protocol/TER.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractVmTest.h>
#include <format>
#include <string>
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<std::uint8_t>(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

View File

@@ -0,0 +1,33 @@
#pragma once
#include <xrpl/tx/wasm/WasmCommon.h>
#include <xrpl/tx/wasm/WasmVM.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
#include <tx/wasm/fixtures/WasmRun.h>
#include <cstdint>
#include <expected>
#include <string_view>
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<EscrowResult, WasmTER>
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