Test the data-shaped and transaction-shaped contract host functions

Completes the `host_functions/` layer: reads, nested objects, arrays,
nested arrays, and the two emit paths, each against a real ledger.

What the shapes turn out to mean, now asserted rather than assumed:

- An account has one data object, and the first write decides whether it
  is a map of keys or a list of elements. Mixing them is refused rather
  than silently replacing one with the other.
- The two string arguments of a nested write are the outer key then the
  inner one. Reading under the swapped pair finds nothing, which is what
  a contract that got the order wrong would see.
- Two arrays under two keys are two arrays.
- A stored integer reads back big-endian, the opposite of the
  little-endian a parameter answers with.

Two more defects are pinned rather than fixed, both the same shape as the
one in the previous commit: reconstructing an `STTx` re-runs its format
check, and the throw lands outside the guard that would have answered
`SubmitTxnFailure`. `emit_txn` cannot emit a transaction that does not
already carry `tfInnerBatchTxn`, because setting the flag means rebuilding
it, and an ordinary Payment's defaulted `sfPaths` is rejected on the way
back in. The contract is told `InternalFatal` and the run stops.

`ContractLedger` now gives each host a transaction of its own; two
identical ones share an id, which a ledger refuses to record twice.
This commit is contained in:
Mayukha Vadari
2026-09-15 18:32:08 -04:00
parent 692315043c
commit 77d8e5bf2e
7 changed files with 516 additions and 0 deletions

View File

@@ -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<ApplyContext>(

View File

@@ -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};
};
// -------------------------------------------------------------------------------------

View File

@@ -0,0 +1,109 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
#include <cstdint>
#include <memory>
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<STTx const>
payment(STAmount amount, std::uint32_t sequence, bool innerFlag = true)
{
return std::make_shared<STTx const>(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

View File

@@ -0,0 +1,90 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
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

View File

@@ -0,0 +1,100 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
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

View File

@@ -0,0 +1,117 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
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

View File

@@ -0,0 +1,94 @@
#include <xrpl/protocol/STJson.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gtest/gtest.h>
#include <tx/wasm/fixtures/ContractHostFixture.h>
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