Add the contract test fixtures and the first real-ledger host tests

`ContractLedger` is the contract counterpart of `WasmLedger`: a real
genesis ledger with a real `ContractHostFunctionsImpl` over it, built the
way `ContractCall::doApply` builds one. It carries no test framework, for
the reason `WasmLedger` carries none — the benchmarks need a ledger and a
host, not GTest's lifecycle — so it joins `xrpl.testkit.wasm`.

`ContractHost` owns the four things the host borrows, in the order it
needs them, and its `finalize()` does what the transactor does once a
contract returns: write the data cache and the events to the ledger, then
apply. Applying alone is not enough, because `ApplyContext` reaches a
sandbox rather than the ledger under it, so `finalize()` commits that too.

`ContractHostFixture` adds the framework and the value constructors a
data test compares against, including `valueWire`, which produces the
bytes a guest writes for a value: the inverse of what `HostContext`
decodes.

`WasmLedger::tracingJournal` is extracted so a derived fixture can build
a tracing host over the same sink.
This commit is contained in:
Mayukha Vadari
2026-09-15 18:15:13 -04:00
parent 3106b79e4b
commit 2641f0c819
7 changed files with 492 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ add_library(
helpers/Account.cpp
helpers/TestSink.cpp
helpers/TxTest.cpp
tx/wasm/fixtures/ContractLedger.cpp
tx/wasm/fixtures/NftSetup.cpp
tx/wasm/fixtures/WasmLedger.cpp
tx/wasm/fixtures/WasmRun.cpp
@@ -69,7 +70,7 @@ foreach(module IN LISTS test_modules)
list(
FILTER sources
EXCLUDE
REGEX "/fixtures/(NftSetup|WasmLedger|WasmRun)\\.cpp$"
REGEX "/fixtures/(ContractLedger|NftSetup|WasmLedger|WasmRun)\\.cpp$"
)
target_sources(xrpl_tests PRIVATE ${sources})

View File

@@ -0,0 +1,87 @@
#pragma once
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAccount.h>
#include <xrpl/protocol/STInteger.h>
#include <xrpl/protocol/STJson.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractLedger.h>
#include <tx/wasm/fixtures/RealHostFixture.h>
#include <cstdint>
#include <memory>
// The GTest layer over `ContractLedger`, holding only what needs the framework. Everything
// that builds a ledger or a host is in `ContractLedger.h`, which the benchmarks can share.
namespace xrpl::test {
// A `ContractLedger` with GTest's lifecycle attached.
struct ContractHostFixture : testing::Test, ContractLedger
{
};
// -------------------------------------------------------------------------------------
// Contract data values.
//
// A value the contract stores is an `STJson::Value` — a `shared_ptr<STBase>` — and these
// build the ones a test compares against. The `SField` each carries does not travel with
// the value: `STJson` writes a value's type byte and its serialization, and reads it back
// under a field of its own choosing, so any field of the right type will do.
// -------------------------------------------------------------------------------------
inline STJson::Value
u8(std::uint8_t value)
{
return std::make_shared<STUInt8>(sfCloseResolution, value);
}
inline STJson::Value
u16(std::uint16_t value)
{
return std::make_shared<STUInt16>(sfTransferFee, value);
}
inline STJson::Value
u32(std::uint32_t value)
{
return std::make_shared<STUInt32>(sfSequence, value);
}
inline STJson::Value
u64(std::uint64_t value)
{
return std::make_shared<STUInt64>(sfIndexNext, value);
}
inline STJson::Value
acct(AccountID const& value)
{
return std::make_shared<STAccount>(sfAccount, value);
}
// A value's canonical serialization, which is what a `get_data_*` call answers — the same
// bytes the guest wrote, without the type byte that preceded them.
inline Bytes
serialization(STJson::Value const& value)
{
Serializer s;
value->add(s);
return Bytes{s.peekData().begin(), s.peekData().end()};
}
// The bytes a guest writes for `value`: its one-byte `SerializedTypeID`, then its
// serialization. The inverse of what `HostContext` decodes.
inline Bytes
valueWire(STJson::Value const& value)
{
Bytes wire{static_cast<std::uint8_t>(value->getSType())};
auto const bytes = serialization(value);
wire.insert(wire.end(), bytes.begin(), bytes.end());
return wire;
}
} // namespace xrpl::test

View File

@@ -0,0 +1,140 @@
#include <tx/wasm/fixtures/ContractLedger.h>
#include <xrpl/ledger/helpers/ContractUtils.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/jss.h>
#include <utility>
namespace xrpl::test {
ContractHost::ContractHost(
std::shared_ptr<STTx const> tx,
std::unique_ptr<ApplyContext> context,
std::unique_ptr<ContractContext> contractContext,
std::unique_ptr<ContractHostFunctionsImpl> host)
: tx_{std::move(tx)}
, context_{std::move(context)}
, contractContext_{std::move(contractContext)}
, host_{std::move(host)}
{
}
ContractHostFunctionsImpl*
ContractHost::operator->() const
{
return host_.get();
}
ContractHostFunctionsImpl&
ContractHost::operator*() const
{
return *host_;
}
ContractContext&
ContractHost::context() const
{
return *contractContext_;
}
ApplyContext&
ContractHost::applyContext() const
{
return *context_;
}
TER
ContractHost::finalize() const
{
auto const result = contract::finalizeContractData(
context_->registry,
context_->view(),
contractContext_->result.contractAccount,
contractContext_->result.dataMap,
contractContext_->result.eventMap,
tx_->getTransactionID());
if (!isTesSuccess(result))
return result;
context_->apply(tesSUCCESS);
// `apply` reaches the sandbox `ApplyContext` holds, not the ledger under it; the
// transactor pushes one into the other, and so does this.
context_->finalize();
return tesSUCCESS;
}
ContractHost
ContractLedger::makeContractHost(beast::Journal journal, ContractHostOptions options)
{
auto const contractKeylet =
options.contractKeylet.value_or(keylet::contract(options.contractHash));
auto const contractAccount = options.contractAccount;
auto const caller = options.caller;
auto tx = std::make_shared<STTx>(ttCONTRACT_CALL, [&](STObject& obj) {
obj.setAccountID(sfAccount, caller);
obj.setAccountID(sfContractAccount, contractAccount);
obj.setFieldVL(sfFunctionName, Blob{'c', 'a', 'l', 'l'});
obj.setFieldU32(sfGas, 1'000'000);
});
auto context = std::make_unique<ApplyContext>(
ledger.getServiceRegistry(),
ledger.getOpenLedger(),
*tx,
tesSUCCESS,
ledger.getOpenLedger().fees().base,
TapNone,
journal);
// The contract account's own sequence, which `build_txn` stamps on each transaction it
// builds. A contract account that does not exist yet starts at the first sequence.
auto const accountSle = context->view().read(keylet::account(contractAccount));
std::uint32_t const nextSequence = accountSle ? accountSle->getFieldU32(sfSequence) : 1;
auto contractContext = std::make_unique<ContractContext>(ContractContext{
.applyCtx = *context,
.instanceParameters = std::move(options.instanceParameters),
.functionParameters = std::move(options.functionParameters),
.built_txns = {},
.expected_etxn_count = 1,
.result =
{
.contractHash = options.contractHash,
.contractKeylet = contractKeylet,
.contractSourceKeylet = keylet::contractSource(options.contractHash),
.contractAccountKeylet = keylet::account(contractAccount),
.contractAccount = contractAccount,
.nextSequence = nextSequence,
.otxnAccount = caller,
.otxnId = tx->getTransactionID(),
},
});
auto host = std::make_unique<ContractHostFunctionsImpl>(*contractContext);
return ContractHost{
std::move(tx), std::move(context), std::move(contractContext), std::move(host)};
}
ContractHost
ContractLedger::makeContractHost(ContractHostOptions options)
{
return makeContractHost(beast::Journal{beast::Journal::getNullSink()}, std::move(options));
}
ContractHost
ContractLedger::makeTracingContractHost(ContractHostOptions options)
{
return makeContractHost(tracingJournal(), std::move(options));
}
std::shared_ptr<SLE const>
ContractLedger::contractData(AccountID const& owner, AccountID const& contractAccount)
{
return ledger.getOpenLedger().read(keylet::contractData(owner, contractAccount));
}
} // namespace xrpl::test

View File

@@ -0,0 +1,128 @@
#pragma once
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STData.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/wasm/ContractContext.h>
#include <xrpl/tx/wasm/ContractHostFuncImpl.h>
#include <helpers/Account.h>
#include <tx/wasm/fixtures/WasmLedger.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <string_view>
#include <vector>
// A real ledger with a real contract host over it, and **no test framework**, for the same
// reason `WasmLedger` has none: a benchmark wants a ledger and a host, not GTest's lifecycle.
// `ContractHostFixture` adds the framework and the assertions on top.
//
// Setup steps here throw (`fixtureFailed`) rather than `EXPECT_`. See `WasmLedger.h`.
namespace xrpl::test {
// The pieces a `ContractHostFunctionsImpl` borrows, kept alive together and in the order the
// host needs them: the transaction the context reads, the context the contract context holds,
// and the contract context the host holds.
class ContractHost
{
public:
ContractHost(
std::shared_ptr<STTx const> tx,
std::unique_ptr<ApplyContext> context,
std::unique_ptr<ContractContext> contractContext,
std::unique_ptr<ContractHostFunctionsImpl> host);
ContractHostFunctionsImpl*
operator->() const;
ContractHostFunctionsImpl&
operator*() const;
// What the contract has accumulated so far: its data cache, its event map, the
// transactions it built and the ones it emitted.
ContractContext&
context() const;
ApplyContext&
applyContext() const;
// Write the contract's data cache and events into the ledger, as `ContractCall::doApply`
// does once the contract returns, then apply the transaction. The data objects are
// readable through `ContractLedger::contractData` afterwards.
TER
finalize() const;
private:
std::shared_ptr<STTx const> tx_;
std::unique_ptr<ApplyContext> context_;
std::unique_ptr<ContractContext> contractContext_;
std::unique_ptr<ContractHostFunctionsImpl> host_;
};
// How a contract host is to be built. Everything has a default that a test does not have to
// think about; a test names only what it is about.
struct ContractHostOptions
{
// The contract's own account, which owns nothing a test has to fund unless the contract
// pays out.
AccountID contractAccount;
// The account whose transaction is running, which is what `otxnAccount` reports.
AccountID caller;
// The contract instance's parameters, as `ContractCall::doApply` reads them off the
// Contract ledger entry.
std::vector<ParameterValueVec> instanceParameters{};
// The parameters this call was given.
std::vector<ParameterValueVec> functionParameters{};
// The contract instance, which is also the key the host's object cache is rooted at.
std::optional<Keylet> contractKeylet{};
uint256 contractHash{1};
};
class ContractLedger : public WasmLedger
{
public:
// A host over a `ttCONTRACT_CALL`, built the way `ContractCall::doApply` builds one.
ContractHost
makeContractHost(ContractHostOptions options);
// The same, with `trace` output captured; read it back with `logged()`.
ContractHost
makeTracingContractHost(ContractHostOptions options);
// The data object `owner` keeps under `contractAccount`, or nullptr if the contract has
// never written one.
[[nodiscard]] std::shared_ptr<SLE const>
contractData(AccountID const& owner, AccountID const& contractAccount);
private:
ContractHost
makeContractHost(beast::Journal journal, ContractHostOptions options);
};
// -------------------------------------------------------------------------------------
// Parameter builders, mirroring what `src/test/jtx/contract.h` puts in a transaction.
// -------------------------------------------------------------------------------------
// A parameter value, as the Contract ledger entry and the ContractCall transaction carry it.
template <class T>
ParameterValueVec
param(T const& value)
{
return ParameterValueVec{STData{sfParameterValue, value}};
}
} // namespace xrpl::test

View File

@@ -271,6 +271,12 @@ WasmLedger::makeTracingHost(
return makeHost(beast::Journal{traceSink_}, leKey, txType, std::move(assembler));
}
beast::Journal
WasmLedger::tracingJournal()
{
return beast::Journal{traceSink_};
}
std::string
WasmLedger::logged() const
{

View File

@@ -166,6 +166,12 @@ public:
static Bytes
toBytes(STNumber const& number);
protected:
// The journal `makeTracingHost` hands its host, whose output `logged()` reads back.
// Held here so it outlives every host a test makes and accumulates across the test.
[[nodiscard]] beast::Journal
tracingJournal();
private:
CaptureSink traceSink_{beast::Severity::Trace};
};

View File

@@ -0,0 +1,123 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
#include <string>
namespace xrpl::test {
// set_data_object_field and get_data_object_field against a real ledger: what the contract
// stores, what it reads back, and who pays for it.
struct SetDataObjectFieldImpl : ContractHostFixture
{
// The account whose data object the contract writes. It pays the object's reserve, so it
// has to exist and have something spare.
Account const alice = fund("alice");
// The contract's own account. A data test never makes it pay anything.
Account const contract = fund("contract");
ContractHost
host()
{
return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()});
}
};
TEST_F(SetDataObjectFieldImpl, WhatIsStoredIsWhatIsReadBack)
{
auto const contractHost = host();
expectValue(contractHost->setDataObjectField(alice.id(), "value_u8", u8(42)), 0);
// The answer is the value's serialization without its type byte: the contract knows the
// type it asked for, so the wire does not repeat it.
expectValue(contractHost->getDataObjectField(alice.id(), "value_u8"), serialization(u8(42)));
}
// The cache is what makes a contract's own writes visible to its own reads: nothing has
// reached the ledger at this point, and the data object may not even exist yet.
TEST_F(SetDataObjectFieldImpl, AWriteIsVisibleToALaterReadInTheSameRun)
{
auto const contractHost = host();
ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(3)));
auto const& [modified, data] = contractHost.context().result.dataMap.at(alice.id());
EXPECT_TRUE(modified) << "a written object is marked for the ledger";
auto const expected = STJson{STJson::Map{{"count", u32(3)}}};
EXPECT_EQ(data.toBlob(), expected.toBlob());
}
TEST_F(SetDataObjectFieldImpl, StoringTwiceUnderOneKeyKeepsTheLastValue)
{
auto const contractHost = host();
ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(3)));
ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(4)));
expectValue(contractHost->getDataObjectField(alice.id(), "count"), serialization(u32(4)));
}
TEST_F(SetDataObjectFieldImpl, WhatIsFinalizedIsVisibleToTheNextCall)
{
{
auto const contractHost = host();
ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(3)));
ASSERT_EQ(contractHost.finalize(), tesSUCCESS);
}
auto const sle = contractData(alice.id(), contract.id());
ASSERT_NE(sle, nullptr) << "finalizing writes the data object to the ledger";
// A host with no cache at all, so this can only be the ledger answering.
expectValue(host()->getDataObjectField(alice.id(), "count"), serialization(u32(3)));
}
TEST_F(SetDataObjectFieldImpl, AnAccountThatDoesNotExistCannotBeWrittenTo)
{
expectError(
host()->setDataObjectField(Account{"ghost"}.id(), "count", u32(1)),
HostFunctionError::InvalidAccount);
}
TEST_F(SetDataObjectFieldImpl, AnAccountWithNoDataObjectHasNoFieldToRead)
{
expectError(
host()->getDataObjectField(alice.id(), "count"), HostFunctionError::LedgerObjNotFound);
}
TEST_F(SetDataObjectFieldImpl, AKeyThatWasNeverStoredIsNotAField)
{
auto const contractHost = host();
ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(3)));
expectError(
contractHost->getDataObjectField(alice.id(), "absent"), HostFunctionError::InvalidField);
}
// An account whose data object is an array is not one an object field can be stored in, and
// the contract is told so rather than having its array replaced.
TEST_F(SetDataObjectFieldImpl, AnArrayIsNotAnObject)
{
auto const contractHost = host();
ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "first", u8(1)));
expectError(
contractHost->setDataObjectField(alice.id(), "count", u32(3)),
HostFunctionError::InvalidState);
}
// The owner pays the data object's reserve, so an account with nothing spare cannot be
// written to at all — the contract cannot spend someone else's reserve for them.
TEST_F(SetDataObjectFieldImpl, AnOwnerWithNoSpareReserveIsRefused)
{
auto const poor = fund("poor", XRP(10));
expectError(
host()->setDataObjectField(poor.id(), "count", u32(3)), HostFunctionError::InvalidState);
}
} // namespace xrpl::test