mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 07:26:51 +00:00
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:
@@ -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})
|
||||
|
||||
|
||||
87
src/tests/libxrpl/tx/wasm/fixtures/ContractHostFixture.h
Normal file
87
src/tests/libxrpl/tx/wasm/fixtures/ContractHostFixture.h
Normal 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
|
||||
140
src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp
Normal file
140
src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp
Normal 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
|
||||
128
src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h
Normal file
128
src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h
Normal 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
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
123
src/tests/libxrpl/tx/wasm/host_functions/SetDataObjectField.cpp
Normal file
123
src/tests/libxrpl/tx/wasm/host_functions/SetDataObjectField.cpp
Normal 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
|
||||
Reference in New Issue
Block a user