diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp index 0402a15f77..6f8a8444bd 100644 --- a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.cpp @@ -79,6 +79,7 @@ ContractLedger::makeContractHost(beast::Journal journal, ContractHostOptions opt obj.setAccountID(sfContractAccount, contractAccount); obj.setFieldVL(sfFunctionName, Blob{'c', 'a', 'l', 'l'}); obj.setFieldU32(sfGas, 1'000'000); + obj.setFieldU32(sfSequence, callSequence_++); }); auto context = std::make_unique( diff --git a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h index 7f94763bd8..d95b50b3f0 100644 --- a/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h +++ b/src/tests/libxrpl/tx/wasm/fixtures/ContractLedger.h @@ -111,6 +111,11 @@ public: private: ContractHost makeContractHost(beast::Journal journal, ContractHostOptions options); + + // Every host gets a transaction of its own. Two identical ones would share an id, and a + // ledger refuses to record the same transaction twice — which a test that finalizes more + // than once would otherwise hit. + std::uint32_t callSequence_{1}; }; // ------------------------------------------------------------------------------------- diff --git a/src/tests/libxrpl/tx/wasm/host_functions/EmitTxn.cpp b/src/tests/libxrpl/tx/wasm/host_functions/EmitTxn.cpp new file mode 100644 index 0000000000..8ad8e3ab5d --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/EmitTxn.cpp @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +// emit_txn — a transaction the contract serialized itself, rather than one it built through +// `build_txn` and `add_txn_field`. +// +// The two paths end in the same place, so what is tested here is the difference: a +// transaction that arrives whole, with whatever the contract chose to put in it. +struct EmitTxnImpl : ContractHostFixture +{ + Account const alice = fund("alice"); + Account const contract = fund("contract", XRP(2000)); + Account const carol = fund("carol"); + + ContractHost + host() + { + return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + } + + // A payment from the contract, assembled the way a contract's own SDK would assemble one. + std::shared_ptr + payment(STAmount amount, std::uint32_t sequence, bool innerFlag = true) + { + return std::make_shared(ttPAYMENT, [&](STObject& obj) { + obj.setAccountID(sfAccount, contract.id()); + obj.setAccountID(sfDestination, carol.id()); + obj.setFieldAmount(sfAmount, amount); + obj.setFieldAmount(sfFee, STAmount{0}); + obj.setFieldU32(sfSequence, sequence); + obj.setFieldVL(sfSigningPubKey, Blob{}); + if (innerFlag) + obj.setFieldU32(sfFlags, tfInnerBatchTxn); + }); + } + + // The contract account's sequence, which an emitted transaction has to carry. + std::uint32_t + contractSequence() + { + return ledger.getOpenLedger().read(keylet::account(contract.id()))->getFieldU32(sfSequence); + } +}; + +TEST_F(EmitTxnImpl, APaymentTheLedgerAcceptsIsQueued) +{ + auto const contractHost = host(); + + expectValue(contractHost->emitTxn(payment(XRP(192), contractSequence())), TERtoInt(tesSUCCESS)); + EXPECT_EQ(contractHost.context().result.emittedTxns.size(), 1U); +} + +// A transaction the contract did not mark as an inner one cannot be emitted at all. +// +// The host means to set the flag for it, and rebuilds the transaction to do so — but +// rebuilding an `STTx` from its own fields re-runs the format check, which rejects a +// defaulted `sfPaths` that the original carried harmlessly. The contract is told +// `InternalFatal`, so the run stops and the transaction reports `tecINTERNAL`. +// +// This pins what happens today. A contract's own choice reading as a node fault belongs +// with the same fix as `EmitBuiltTxnImpl.ATransactionMissingARequiredFieldStopsTheRun`. +TEST_F(EmitTxnImpl, ATransactionWithoutTheInnerFlagStopsTheRun) +{ + auto const contractHost = host(); + + expectError( + contractHost->emitTxn(payment(XRP(192), contractSequence(), false)), + HostFunctionError::InternalFatal); + EXPECT_TRUE(contractHost.context().result.emittedTxns.empty()); +} + +// One the contract did mark keeps the flag, which is what the transactor reads to know it is +// applying an inner transaction. +TEST_F(EmitTxnImpl, AnEmittedTransactionCarriesTheInnerFlag) +{ + auto const contractHost = host(); + + ASSERT_TRUE(contractHost->emitTxn(payment(XRP(192), contractSequence()))); + + ASSERT_EQ(contractHost.context().result.emittedTxns.size(), 1U); + EXPECT_TRUE(contractHost.context().result.emittedTxns.front()->isFlag(tfInnerBatchTxn)); +} + +// The sequence is the contract's own, and the ledger checks it like any other: a stale one +// is refused with the code that says so. +TEST_F(EmitTxnImpl, AStaleSequenceIsRefused) +{ + auto const contractHost = host(); + + auto const result = contractHost->emitTxn(payment(XRP(1), contractSequence() - 1)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, TERtoInt(tefPAST_SEQ)); + EXPECT_TRUE(contractHost.context().result.emittedTxns.empty()); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_functions/GetDataObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_functions/GetDataObjectField.cpp new file mode 100644 index 0000000000..893ebd0bde --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/GetDataObjectField.cpp @@ -0,0 +1,90 @@ +#include +#include + +#include +#include + +namespace xrpl::test { + +// get_data_object_field — reading one key out of an account's data object. +// +// What it answers is the value's canonical serialization with no type byte: the contract +// asked for a key, and the type is the one the value was stored as. +struct GetDataObjectFieldImpl : ContractHostFixture +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + ContractHost + host() + { + return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + } + + // An account whose data object is already on the ledger, so a read has something to find + // without a write in the same run. + void + store(std::string const& key, STJson::Value const& value) + { + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), key, value)); + ASSERT_EQ(contractHost.finalize(), tesSUCCESS); + } +}; + +TEST_F(GetDataObjectFieldImpl, EveryWidthComesBackAsItsCanonicalBytes) +{ + store("value_u8", u8(42)); + store("value_u16", u16(1234)); + store("count", u32(3)); + store("total", u64(9'876'543'210)); + + auto const contractHost = host(); + expectValue(contractHost->getDataObjectField(alice.id(), "value_u8"), serialization(u8(42))); + expectValue( + contractHost->getDataObjectField(alice.id(), "value_u16"), serialization(u16(1234))); + expectValue(contractHost->getDataObjectField(alice.id(), "count"), serialization(u32(3))); + expectValue( + contractHost->getDataObjectField(alice.id(), "total"), serialization(u64(9'876'543'210))); +} + +// An integer's canonical serialization is big-endian, which is the opposite of what +// `instance_param` answers with. The two live side by side in the ABI, so this is asserted +// against the bytes rather than against a helper that could share the mistake. +TEST_F(GetDataObjectFieldImpl, AnIntegerComesBackBigEndian) +{ + store("count", u32(0x01020304)); + + expectValue(host()->getDataObjectField(alice.id(), "count"), (Bytes{0x01, 0x02, 0x03, 0x04})); +} + +TEST_F(GetDataObjectFieldImpl, AnAccountIsItsLengthAndItsTwentyBytes) +{ + store("owner", acct(contract.id())); + + auto const expected = serialization(acct(contract.id())); + ASSERT_EQ(expected.size(), 21U) << "an account field carries its length"; + expectValue(host()->getDataObjectField(alice.id(), "owner"), expected); +} + +TEST_F(GetDataObjectFieldImpl, AnAccountWithNoDataObjectHasNothingToRead) +{ + expectError( + host()->getDataObjectField(alice.id(), "count"), HostFunctionError::LedgerObjNotFound); +} + +TEST_F(GetDataObjectFieldImpl, AKeyThatIsNotThereIsNotAField) +{ + store("count", u32(3)); + + expectError(host()->getDataObjectField(alice.id(), "absent"), HostFunctionError::InvalidField); +} + +TEST_F(GetDataObjectFieldImpl, AnAccountThatDoesNotExistIsRefusedBeforeItsDataIsLookedFor) +{ + expectError( + host()->getDataObjectField(Account{"ghost"}.id(), "count"), + HostFunctionError::InvalidAccount); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_functions/SetDataArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_functions/SetDataArrayElementField.cpp new file mode 100644 index 0000000000..26a2658722 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/SetDataArrayElementField.cpp @@ -0,0 +1,100 @@ +#include +#include + +#include +#include + +namespace xrpl::test { + +// set_data_array_element_field and its reader — an account's data object *as an array*. +// +// An account has one data object, and it is either a map of keys or a list of elements. The +// first array write decides which, and there is no going back inside a run. +struct SetDataArrayElementFieldImpl : ContractHostFixture +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + ContractHost + host() + { + return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + } +}; + +TEST_F(SetDataArrayElementFieldImpl, EachElementKeepsItsOwnValue) +{ + auto const contractHost = host(); + + expectValue(contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10)), 0); + expectValue(contractHost->setDataArrayElementField(alice.id(), 1, "amount", u32(20)), 0); + + expectValue( + contractHost->getDataArrayElementField(alice.id(), 0, "amount"), serialization(u32(10))); + expectValue( + contractHost->getDataArrayElementField(alice.id(), 1, "amount"), serialization(u32(20))); +} + +TEST_F(SetDataArrayElementFieldImpl, OneElementHoldsSeveralKeys) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10))); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "kind", u8(2))); + + expectValue( + contractHost->getDataArrayElementField(alice.id(), 0, "amount"), serialization(u32(10))); + expectValue( + contractHost->getDataArrayElementField(alice.id(), 0, "kind"), serialization(u8(2))); +} + +TEST_F(SetDataArrayElementFieldImpl, WhatIsFinalizedIsVisibleToTheNextCall) +{ + { + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10))); + ASSERT_EQ(contractHost.finalize(), tesSUCCESS); + } + + expectValue(host()->getDataArrayElementField(alice.id(), 0, "amount"), serialization(u32(10))); +} + +// An object is not an array. A contract that has written a key cannot then write an element, +// and is told so rather than having its object replaced by a list. +TEST_F(SetDataArrayElementFieldImpl, AnObjectIsNotAnArray) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataObjectField(alice.id(), "count", u32(1))); + + expectError( + contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10)), + HostFunctionError::InvalidState); +} + +TEST_F(SetDataArrayElementFieldImpl, AnElementThatWasNeverWrittenIsNotThere) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10))); + + expectError( + contractHost->getDataArrayElementField(alice.id(), 1, "amount"), + HostFunctionError::InvalidField); +} + +TEST_F(SetDataArrayElementFieldImpl, AKeyThatIsNotInTheElementIsNotAField) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "amount", u32(10))); + + expectError( + contractHost->getDataArrayElementField(alice.id(), 0, "absent"), + HostFunctionError::InvalidField); +} + +TEST_F(SetDataArrayElementFieldImpl, AnAccountThatDoesNotExistCannotBeWrittenTo) +{ + expectError( + host()->setDataArrayElementField(Account{"ghost"}.id(), 0, "amount", u32(1)), + HostFunctionError::InvalidAccount); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedArrayElementField.cpp b/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedArrayElementField.cpp new file mode 100644 index 0000000000..d6642855f3 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedArrayElementField.cpp @@ -0,0 +1,117 @@ +#include +#include + +#include +#include + +namespace xrpl::test { + +// set_data_nested_array_element_field and its reader — an array held under a key of an +// account's data object, rather than the object itself being one. +// +// The arguments are the outer key, the element index, and the key inside that element: the +// index sits between the two strings on the wire, which is the shape nothing else has. +struct SetDataNestedArrayElementFieldImpl : ContractHostFixture +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + ContractHost + host() + { + return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + } +}; + +TEST_F(SetDataNestedArrayElementFieldImpl, EachElementKeepsItsOwnValue) +{ + auto const contractHost = host(); + + expectValue( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55)), 0); + expectValue( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 1, "id", u32(77)), 0); + + expectValue( + contractHost->getDataNestedArrayElementField(alice.id(), "items", 0, "id"), + serialization(u32(55))); + expectValue( + contractHost->getDataNestedArrayElementField(alice.id(), "items", 1, "id"), + serialization(u32(77))); +} + +TEST_F(SetDataNestedArrayElementFieldImpl, OneElementHoldsSeveralKeys) +{ + auto const contractHost = host(); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55))); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "price", u32(12))); + + expectValue( + contractHost->getDataNestedArrayElementField(alice.id(), "items", 0, "price"), + serialization(u32(12))); +} + +// Two arrays under two keys are two arrays: an element of one is not an element of the +// other, which is what having the outer key on the wire is for. +TEST_F(SetDataNestedArrayElementFieldImpl, ArraysUnderDifferentKeysAreSeparate) +{ + auto const contractHost = host(); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55))); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "orders", 0, "id", u32(77))); + + expectValue( + contractHost->getDataNestedArrayElementField(alice.id(), "items", 0, "id"), + serialization(u32(55))); + expectValue( + contractHost->getDataNestedArrayElementField(alice.id(), "orders", 0, "id"), + serialization(u32(77))); +} + +TEST_F(SetDataNestedArrayElementFieldImpl, WhatIsFinalizedIsVisibleToTheNextCall) +{ + { + auto const contractHost = host(); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55))); + ASSERT_EQ(contractHost.finalize(), tesSUCCESS); + } + + expectValue( + host()->getDataNestedArrayElementField(alice.id(), "items", 0, "id"), + serialization(u32(55))); +} + +// The data object holds the array, so the object itself may not be one. +TEST_F(SetDataNestedArrayElementFieldImpl, AnArrayRootHasNoKeyToHoldAnArrayUnder) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "first", u8(1))); + + expectError( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55)), + HostFunctionError::InvalidState); +} + +TEST_F(SetDataNestedArrayElementFieldImpl, AnElementThatWasNeverWrittenIsNotThere) +{ + auto const contractHost = host(); + ASSERT_TRUE( + contractHost->setDataNestedArrayElementField(alice.id(), "items", 0, "id", u32(55))); + + expectError( + contractHost->getDataNestedArrayElementField(alice.id(), "items", 1, "id"), + HostFunctionError::InvalidField); +} + +TEST_F(SetDataNestedArrayElementFieldImpl, AnAccountThatDoesNotExistCannotBeWrittenTo) +{ + expectError( + host()->setDataNestedArrayElementField(Account{"ghost"}.id(), "items", 0, "id", u32(1)), + HostFunctionError::InvalidAccount); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedObjectField.cpp b/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedObjectField.cpp new file mode 100644 index 0000000000..50cffc7c1b --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_functions/SetDataNestedObjectField.cpp @@ -0,0 +1,94 @@ +#include +#include + +#include +#include + +namespace xrpl::test { + +// set_data_nested_object_field and its reader — one level of nesting inside an account's +// data object. +// +// The two string arguments are the outer key then the inner one, in that order on the wire. +// A contract that swaps them writes somewhere else entirely, which is why the order is +// asserted rather than assumed. +struct SetDataNestedObjectFieldImpl : ContractHostFixture +{ + Account const alice = fund("alice"); + Account const contract = fund("contract"); + + ContractHost + host() + { + return makeContractHost({.contractAccount = contract.id(), .caller = alice.id()}); + } +}; + +TEST_F(SetDataNestedObjectFieldImpl, WhatIsStoredIsWhatIsReadBack) +{ + auto const contractHost = host(); + + expectValue(contractHost->setDataNestedObjectField(alice.id(), "stats", "score", u32(9999)), 0); + + expectValue( + contractHost->getDataNestedObjectField(alice.id(), "stats", "score"), + serialization(u32(9999))); +} + +// The outer key comes first. Read under the swapped pair and there is nothing there, which +// is what a contract that got the order wrong would see. +TEST_F(SetDataNestedObjectFieldImpl, TheOuterKeyIsTheFirstOne) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataNestedObjectField(alice.id(), "stats", "score", u32(1))); + + expectError( + contractHost->getDataNestedObjectField(alice.id(), "score", "stats"), + HostFunctionError::InvalidField); +} + +TEST_F(SetDataNestedObjectFieldImpl, OneObjectHoldsSeveralKeys) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataNestedObjectField(alice.id(), "stats", "score", u32(1))); + ASSERT_TRUE(contractHost->setDataNestedObjectField(alice.id(), "stats", "level", u32(2))); + + expectValue( + contractHost->getDataNestedObjectField(alice.id(), "stats", "score"), + serialization(u32(1))); + expectValue( + contractHost->getDataNestedObjectField(alice.id(), "stats", "level"), + serialization(u32(2))); +} + +TEST_F(SetDataNestedObjectFieldImpl, WhatIsFinalizedIsVisibleToTheNextCall) +{ + { + auto const contractHost = host(); + ASSERT_TRUE( + contractHost->setDataNestedObjectField(alice.id(), "stats", "score", u32(9999))); + ASSERT_EQ(contractHost.finalize(), tesSUCCESS); + } + + expectValue( + host()->getDataNestedObjectField(alice.id(), "stats", "score"), serialization(u32(9999))); +} + +TEST_F(SetDataNestedObjectFieldImpl, AnArrayIsNotAnObject) +{ + auto const contractHost = host(); + ASSERT_TRUE(contractHost->setDataArrayElementField(alice.id(), 0, "first", u8(1))); + + expectError( + contractHost->setDataNestedObjectField(alice.id(), "stats", "score", u32(1)), + HostFunctionError::InvalidState); +} + +TEST_F(SetDataNestedObjectFieldImpl, AnAccountThatDoesNotExistCannotBeWrittenTo) +{ + expectError( + host()->setDataNestedObjectField(Account{"ghost"}.id(), "stats", "score", u32(1)), + HostFunctionError::InvalidAccount); +} + +} // namespace xrpl::test