diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index f8c53e02bd..86f13c0a0e 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -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}) diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractHostFixture.h b/src/tests/libxrpl/tx/wasm/fixtures/ContractHostFixture.h new file mode 100644 index 0000000000..c7e30dbae4 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractHostFixture.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +// 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` — 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(sfCloseResolution, value); +} + +inline STJson::Value +u16(std::uint16_t value) +{ + return std::make_shared(sfTransferFee, value); +} + +inline STJson::Value +u32(std::uint32_t value) +{ + return std::make_shared(sfSequence, value); +} + +inline STJson::Value +u64(std::uint64_t value) +{ + return std::make_shared(sfIndexNext, value); +} + +inline STJson::Value +acct(AccountID const& value) +{ + return std::make_shared(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(value->getSType())}; + auto const bytes = serialization(value); + wire.insert(wire.end(), bytes.begin(), bytes.end()); + return wire; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp new file mode 100644 index 0000000000..0402a15f77 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp @@ -0,0 +1,140 @@ +#include + +#include +#include +#include +#include + +#include + +namespace xrpl::test { + +ContractHost::ContractHost( + std::shared_ptr tx, + std::unique_ptr context, + std::unique_ptr contractContext, + std::unique_ptr 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(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( + 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{ + .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(*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 +ContractLedger::contractData(AccountID const& owner, AccountID const& contractAccount) +{ + return ledger.getOpenLedger().read(keylet::contractData(owner, contractAccount)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h new file mode 100644 index 0000000000..7f94763bd8 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +// 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 tx, + std::unique_ptr context, + std::unique_ptr contractContext, + std::unique_ptr 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 tx_; + std::unique_ptr context_; + std::unique_ptr contractContext_; + std::unique_ptr 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 instanceParameters{}; + + // The parameters this call was given. + std::vector functionParameters{}; + + // The contract instance, which is also the key the host's object cache is rooted at. + std::optional 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 + 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 +ParameterValueVec +param(T const& value) +{ + return ParameterValueVec{STData{sfParameterValue, value}}; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.cpp b/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.cpp index 887bf4fbab..6f4a82cd32 100644 --- a/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.cpp +++ b/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.cpp @@ -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 { diff --git a/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.h b/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.h index abe6aa8e26..464e951ce6 100644 --- a/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.h +++ b/src/tests/libxrpl/tx/wasm/fixtures/WasmLedger.h @@ -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}; }; diff --git a/src/tests/libxrpl/tx/wasm/host_functions/SetDataObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_functions/SetDataObjectField.cpp new file mode 100644 index 0000000000..66237c5c96 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/SetDataObjectField.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include +#include + +#include + +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