mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 23:38:08 +00:00
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:
133
src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp
Normal file
133
src/tests/libxrpl/tx/wasm/e2e/ContractData.cpp
Normal 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
|
||||
104
src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp
Normal file
104
src/tests/libxrpl/tx/wasm/e2e/ContractEmit.cpp
Normal 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
|
||||
68
src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp
Normal file
68
src/tests/libxrpl/tx/wasm/e2e/ContractEvent.cpp
Normal 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
|
||||
33
src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h
Normal file
33
src/tests/libxrpl/tx/wasm/fixtures/ContractVmTest.h
Normal 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
|
||||
Reference in New Issue
Block a user