diff --git a/src/data/DBHelpers.hpp b/src/data/DBHelpers.hpp index 1df2532d7..1fe40e637 100644 --- a/src/data/DBHelpers.hpp +++ b/src/data/DBHelpers.hpp @@ -211,6 +211,9 @@ struct MPTokenIssuanceTransactionsData { std::uint32_t ledgerSequence{}; std::uint32_t transactionIndex{}; xrpl::uint256 txHash; + + bool + operator==(MPTokenIssuanceTransactionsData const&) const = default; }; /** diff --git a/src/etl/MPTHelpers.cpp b/src/etl/MPTHelpers.cpp index 3aa3de6f9..975dc6b7f 100644 --- a/src/etl/MPTHelpers.cpp +++ b/src/etl/MPTHelpers.cpp @@ -1,10 +1,15 @@ #include "data/DBHelpers.hpp" #include "util/Assert.hpp" +#include #include +#include #include +#include #include +#include #include +#include #include #include #include @@ -44,6 +49,112 @@ getMPTHolderFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const&) return holders; } +namespace { + +using MPTokenIssuanceIDs = boost::container::flat_set; + +/** + * @brief Derive the MPTokenIssuanceID from an affected node in transaction metadata. + * + * @param node An entry of the metadata's AffectedNodes array. + * @return The 192-bit issuance ID if the node is an MPTokenIssuance or MPToken object. + */ +std::optional +getMPTokenIssuanceIDFromNode(xrpl::STObject const& node) +{ + auto const entryType = node.getFieldU16(xrpl::sfLedgerEntryType); + if (entryType != xrpl::ltMPTOKEN && entryType != xrpl::ltMPTOKEN_ISSUANCE) + return std::nullopt; + + auto const& fieldsName = + node.getFName() == xrpl::sfCreatedNode ? xrpl::sfNewFields : xrpl::sfFinalFields; + if (not node.isFieldPresent(fieldsName)) + return std::nullopt; + + auto const& fields = node.peekAtField(fieldsName).downcast(); + + if (entryType == xrpl::ltMPTOKEN) { + if (not fields.isFieldPresent(xrpl::sfMPTokenIssuanceID)) + return std::nullopt; + + return fields[xrpl::sfMPTokenIssuanceID]; + } + + // MPTokenIssuance objects carry no sfMPTokenIssuanceID, and the node's ledger key is a + // one-way hash that does not embed the ID, so reconstruct it from sfSequence and sfIssuer + if (not fields.isFieldPresent(xrpl::sfSequence) || not fields.isFieldPresent(xrpl::sfIssuer)) + return std::nullopt; + + return xrpl::makeMptID( + fields.getFieldU32(xrpl::sfSequence), fields.getAccountID(xrpl::sfIssuer) + ); +} + +void +addMPTokenIssuanceIDsFromTx(MPTokenIssuanceIDs& issuanceIDs, xrpl::STTx const& sttx) +{ + if (sttx.isFieldPresent(xrpl::sfMPTokenIssuanceID)) + issuanceIDs.insert(sttx.getFieldH192(xrpl::sfMPTokenIssuanceID)); + + for (xrpl::STBase const& field : sttx) { + switch (field.getSType()) { + case xrpl::STI_AMOUNT: { + auto const& amount = field.downcast(); + if (amount.holds()) + issuanceIDs.insert(amount.get().getMptID()); + break; + } + case xrpl::STI_ISSUE: { + auto const& issue = field.downcast(); + if (issue.holds()) + issuanceIDs.insert(issue.value().get().getMptID()); + break; + } + default: + break; + } + } +} + +} // namespace + +std::vector +getMPTokenIssuanceTxsFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const& sttx) +{ + // Collect each distinct issuance only once per transaction; the same set of affected accounts + // is attached to every record produced below. + MPTokenIssuanceIDs issuanceIDs; + + if (txMeta.getResultTER() == xrpl::tesSUCCESS) { + for (auto const& node : txMeta.getNodes()) { + if (auto const issuanceID = getMPTokenIssuanceIDFromNode(node); issuanceID.has_value()) + issuanceIDs.insert(*issuanceID); + } + } + + addMPTokenIssuanceIDsFromTx(issuanceIDs, sttx); + + if (issuanceIDs.empty()) + return {}; + + auto const accounts = txMeta.getAffectedAccounts(); + + std::vector result; + result.reserve(issuanceIDs.size()); + for (auto const& issuanceID : issuanceIDs) { + result.push_back( + MPTokenIssuanceTransactionsData{ + .mptIssuanceID = issuanceID, + .accounts = accounts, + .ledgerSequence = txMeta.getLgrSeq(), + .transactionIndex = txMeta.getIndex(), + .txHash = sttx.getTransactionID() + } + ); + } + return result; +} + std::optional getMPTHolderFromObj(std::string const& key, std::string const& blob) { @@ -58,7 +169,7 @@ getMPTHolderFromObj(std::string const& key, std::string const& blob) ); if (sle.getFieldU16(xrpl::sfLedgerEntryType) != xrpl::ltMPTOKEN) - return {}; + return std::nullopt; auto const mptIssuanceID = sle[xrpl::sfMPTokenIssuanceID]; auto const holder = sle.getAccountID(xrpl::sfAccount); diff --git a/src/etl/MPTHelpers.hpp b/src/etl/MPTHelpers.hpp index 0335cdb62..cdc3405ad 100644 --- a/src/etl/MPTHelpers.hpp +++ b/src/etl/MPTHelpers.hpp @@ -33,4 +33,22 @@ getMPTHolderFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const& sttx); std::optional getMPTHolderFromObj(std::string const& key, std::string const& blob); +/** + * @brief Pull MPT issuance transaction index data from a transaction. + * + * @note This scans the transaction's metadata for affected MPTokenIssuance/MPToken ledger objects + * and transaction fields for attached MPTokenIssuanceID/MPT issue references. It produces one + * record per distinct issuance, each carrying the full set of affected accounts. Transaction fields + * are scanned so failed transactions that carry an issuance reference are indexed even when + * metadata has no affected MPT objects. Used by live ETL and reused by the historical backfill + * migrator. + * + * @param txMeta Transaction metadata. + * @param sttx The transaction. + * @return One record per distinct MPT issuance referenced by metadata or transaction fields; empty + * if no MPT issuance reference is found. + */ +std::vector +getMPTokenIssuanceTxsFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const& sttx); + } // namespace etl diff --git a/src/etl/impl/ext/MPT.cpp b/src/etl/impl/ext/MPT.cpp index 552ee71d8..dd07654ab 100644 --- a/src/etl/impl/ext/MPT.cpp +++ b/src/etl/impl/ext/MPT.cpp @@ -8,7 +8,9 @@ #include +#include #include +#include #include #include #include @@ -25,7 +27,7 @@ MPTExt::onLedgerData(model::LedgerData const& data) { LOG(log_.trace()) << "got TXS cnt = " << data.transactions.size() << "; OBJS size = " << data.objects.size(); - writeMPTHoldersFromTransactions(data); + writeMPTDataFromTransactions(data); } void @@ -40,21 +42,50 @@ void MPTExt::onInitialData(model::LedgerData const& data) { LOG(log_.trace()) << "got initial TXS cnt = " << data.transactions.size(); - writeMPTHoldersFromTransactions(data); + writeMPTDataFromTransactions(data); } void -MPTExt::writeMPTHoldersFromTransactions(model::LedgerData const& data) +MPTExt::writeMPTDataFromTransactions(model::LedgerData const& data) { std::vector holders; + std::vector issuanceTxs; + std::size_t indexRowsWritten = 0; + static constexpr std::size_t kIndexRowsPerTxWarningThreshold = 1000; for (auto const& tx : data.transactions) { auto const mptHolders = getMPTHolderFromTx(tx.meta, tx.sttx); holders.append_range(mptHolders); + + auto txIndexData = getMPTokenIssuanceTxsFromTx(tx.meta, tx.sttx); + + std::size_t txIndexRows = 0; + for (auto const& record : txIndexData) + txIndexRows += 1 + record.accounts.size(); + + if (txIndexRows > kIndexRowsPerTxWarningThreshold) { + LOG(log_.warn()) << "MPT issuance tx index fanout of " << txIndexRows + << " rows exceeds the expected bound of " + << kIndexRowsPerTxWarningThreshold << " for tx " + << xrpl::strHex(tx.id); + } + + indexRowsWritten += txIndexRows; + issuanceTxs.insert( + issuanceTxs.end(), + std::make_move_iterator(txIndexData.begin()), + std::make_move_iterator(txIndexData.end()) + ); } if (not holders.empty()) backend_->writeMPTHolders(holders); + + if (not issuanceTxs.empty()) { + backend_->writeMPTokenIssuanceTransactions(issuanceTxs); + backend_->writeAccountMPTokenIssuanceTransactions(issuanceTxs); + issuanceTxIndexRowsWritten_.get() += static_cast(indexRowsWritten); + } } } // namespace etl::impl diff --git a/src/etl/impl/ext/MPT.hpp b/src/etl/impl/ext/MPT.hpp index 2f0c3719b..9af3e8559 100644 --- a/src/etl/impl/ext/MPT.hpp +++ b/src/etl/impl/ext/MPT.hpp @@ -3,6 +3,8 @@ #include "data/BackendInterface.hpp" #include "etl/Models.hpp" #include "util/log/Logger.hpp" +#include "util/prometheus/Counter.hpp" +#include "util/prometheus/Prometheus.hpp" #include #include @@ -10,6 +12,7 @@ #include #include +#include #include namespace etl::impl { @@ -18,6 +21,13 @@ class MPTExt { std::shared_ptr backend_; util::Logger log_{"ETL"}; + std::reference_wrapper issuanceTxIndexRowsWritten_ = + PrometheusService::counterInt( + "etl_mpt_issuance_tx_index_rows_written_total", + {}, + "Total number of MPT issuance transaction index rows written by ETL" + ); + public: explicit MPTExt(std::shared_ptr backend); @@ -32,7 +42,7 @@ public: private: void - writeMPTHoldersFromTransactions(model::LedgerData const& data); + writeMPTDataFromTransactions(model::LedgerData const& data); }; } // namespace etl::impl diff --git a/tests/common/CMakeLists.txt b/tests/common/CMakeLists.txt index bd5f7c52b..51b41e6a6 100644 --- a/tests/common/CMakeLists.txt +++ b/tests/common/CMakeLists.txt @@ -7,6 +7,7 @@ target_sources( util/BinaryTestObject.cpp util/CallWithTimeout.cpp util/LoggerFixtures.cpp + util/MPTokenTestObjects.cpp util/MockAssert.cpp util/StringUtils.cpp util/TestHttpClient.cpp diff --git a/tests/common/util/MPTokenTestObjects.cpp b/tests/common/util/MPTokenTestObjects.cpp new file mode 100644 index 000000000..f9ac46141 --- /dev/null +++ b/tests/common/util/MPTokenTestObjects.cpp @@ -0,0 +1,54 @@ +#include "util/MPTokenTestObjects.hpp" + +#include "util/TestObject.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace util { + +xrpl::STObject +createMPTokenNode( + xrpl::SField const& nodeType, + xrpl::uint192 const& issuanceID, + std::string_view holder +) +{ + auto const& fieldsName = + nodeType == xrpl::sfCreatedNode ? xrpl::sfNewFields : xrpl::sfFinalFields; + + xrpl::STObject fields(fieldsName); + fields.setAccountID(xrpl::sfAccount, ::getAccountIdWithString(holder)); + fields[xrpl::sfMPTokenIssuanceID] = issuanceID; + + xrpl::STObject node(nodeType); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +xrpl::STObject +createMPTokenIssuanceNode(xrpl::SField const& nodeType, std::uint32_t seq, std::string_view issuer) +{ + auto const& fieldsName = + nodeType == xrpl::sfCreatedNode ? xrpl::sfNewFields : xrpl::sfFinalFields; + + xrpl::STObject fields(fieldsName); + fields.setFieldU32(xrpl::sfSequence, seq); + fields.setAccountID(xrpl::sfIssuer, ::getAccountIdWithString(issuer)); + + xrpl::STObject node(nodeType); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN_ISSUANCE); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +} // namespace util diff --git a/tests/common/util/MPTokenTestObjects.hpp b/tests/common/util/MPTokenTestObjects.hpp new file mode 100644 index 000000000..befbb8106 --- /dev/null +++ b/tests/common/util/MPTokenTestObjects.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace util { + +[[nodiscard]] xrpl::STObject +createMPTokenNode( + xrpl::SField const& nodeType, + xrpl::uint192 const& issuanceID, + std::string_view holder +); + +[[nodiscard]] xrpl::STObject +createMPTokenIssuanceNode(xrpl::SField const& nodeType, std::uint32_t seq, std::string_view issuer); + +} // namespace util diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 291f7300f..a5a35a406 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -48,6 +48,7 @@ target_sources( etl/LoadBalancerTests.cpp etl/LoadingTests.cpp etl/MonitorTests.cpp + etl/MPTHelpersTests.cpp etl/NetworkValidatedLedgersTests.cpp etl/NFTHelpersTests.cpp etl/RegistryTests.cpp diff --git a/tests/unit/etl/MPTHelpersTests.cpp b/tests/unit/etl/MPTHelpersTests.cpp new file mode 100644 index 000000000..5ea6ab482 --- /dev/null +++ b/tests/unit/etl/MPTHelpersTests.cpp @@ -0,0 +1,453 @@ +#include "data/DBHelpers.hpp" +#include "etl/MPTHelpers.hpp" +#include "util/MPTokenTestObjects.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr auto kAccount = "rM2AGCCCRb373FRuD8wHyUwUsh2dV4BW5Q"; +constexpr auto kAccount2 = "rnd1nHuzceyQDqnLH8urWNr4QBKt4v7WVk"; +constexpr auto kIssuer = "rK1EX542EgA9m948JrJRaEzwLVEhqWvnr9"; +constexpr auto kTX = "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8"; +constexpr std::uint32_t kIssuanceSeq = 7; +constexpr std::uint32_t kLedgerSeq = 99; +constexpr std::uint32_t kTxIndex = 4; + +xrpl::Slice const kSlice("test", 4); + +xrpl::uint192 +defaultIssuanceID() +{ + return xrpl::makeMptID(kIssuanceSeq, getAccountIdWithString(kIssuer)); +} + +xrpl::STObject +createAccountRootNode(std::string_view account) +{ + xrpl::STObject fields(xrpl::sfFinalFields); + fields.setAccountID(xrpl::sfAccount, getAccountIdWithString(account)); + + xrpl::STObject node(xrpl::sfModifiedNode); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltACCOUNT_ROOT); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +xrpl::STObject +createMPTokenNodeWithoutIssuanceID() +{ + xrpl::STObject fields(xrpl::sfFinalFields); + fields.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + + xrpl::STObject node(xrpl::sfModifiedNode); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +xrpl::STObject +createMPTokenIssuanceNodeWithoutIssuer() +{ + xrpl::STObject fields(xrpl::sfFinalFields); + fields.setFieldU32(xrpl::sfSequence, kIssuanceSeq); + + xrpl::STObject node(xrpl::sfModifiedNode); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN_ISSUANCE); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +xrpl::TxMeta +createTxMeta(std::vector nodes, int result = xrpl::tesSUCCESS) +{ + xrpl::STObject metaObj(xrpl::sfTransactionMetaData); + metaObj.setFieldU8(xrpl::sfTransactionResult, result); + metaObj.setFieldU32(xrpl::sfTransactionIndex, kTxIndex); + + xrpl::STArray affectedNodes(xrpl::sfAffectedNodes); + for (auto& node : nodes) + affectedNodes.push_back(std::move(node)); + metaObj.setFieldArray(xrpl::sfAffectedNodes, affectedNodes); + + return xrpl::TxMeta{xrpl::uint256(kTX), kLedgerSeq, metaObj.getSerializer().peekData()}; +} + +xrpl::STTx +createTx(xrpl::TxType type) +{ + xrpl::STObject obj(xrpl::sfTransaction); + obj.setFieldU16(xrpl::sfTransactionType, type); + obj.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + obj.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10, false)); + obj.setFieldU32(xrpl::sfSequence, 1); + obj.setFieldVL(xrpl::sfSigningPubKey, kSlice); + + // Satisfy the per-type required fields of the SOTemplate + switch (type) { + case xrpl::ttPAYMENT: + obj.setFieldAmount(xrpl::sfAmount, xrpl::STAmount(100, false)); + obj.setAccountID(xrpl::sfDestination, getAccountIdWithString(kAccount2)); + break; + case xrpl::ttCLAWBACK: + obj.setFieldAmount(xrpl::sfAmount, xrpl::STAmount(100, false)); + break; + case xrpl::ttOFFER_CREATE: + obj.setFieldAmount(xrpl::sfTakerPays, xrpl::STAmount(100, false)); + obj.setFieldAmount(xrpl::sfTakerGets, xrpl::STAmount(200, false)); + break; + case xrpl::ttAMM_DEPOSIT: + obj.setFieldIssue(xrpl::sfAsset, xrpl::STIssue{xrpl::sfAsset, xrpl::xrpIssue()}); + obj.setFieldIssue(xrpl::sfAsset2, xrpl::STIssue{xrpl::sfAsset2, xrpl::xrpIssue()}); + break; + case xrpl::ttMPTOKEN_ISSUANCE_DESTROY: + case xrpl::ttMPTOKEN_ISSUANCE_SET: + obj[xrpl::sfMPTokenIssuanceID] = defaultIssuanceID(); + break; + default: + break; + } + + auto const serialized = obj.getSerializer(); + return xrpl::STTx{xrpl::SerialIter{serialized.slice()}}; +} + +xrpl::STTx +createPaymentTxWithMPTAmount(xrpl::uint192 const& issuanceID) +{ + xrpl::STObject obj(xrpl::sfTransaction); + obj.setFieldU16(xrpl::sfTransactionType, xrpl::ttPAYMENT); + obj.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + obj.setFieldAmount( + xrpl::sfAmount, xrpl::STAmount(xrpl::MPTAmount{100}, xrpl::MPTIssue{issuanceID}) + ); + obj.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10, false)); + obj.setAccountID(xrpl::sfDestination, getAccountIdWithString(kAccount2)); + obj.setFieldU32(xrpl::sfSequence, 1); + obj.setFieldVL(xrpl::sfSigningPubKey, kSlice); + + auto const serialized = obj.getSerializer(); + return xrpl::STTx{xrpl::SerialIter{serialized.slice()}}; +} + +xrpl::STTx +createAMMDepositTxWithMPTIssue(xrpl::uint192 const& issuanceID) +{ + xrpl::STObject obj(xrpl::sfTransaction); + obj.setFieldU16(xrpl::sfTransactionType, xrpl::ttAMM_DEPOSIT); + obj.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + obj.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10, false)); + obj.setFieldU32(xrpl::sfSequence, 1); + obj.setFieldVL(xrpl::sfSigningPubKey, kSlice); + obj.setFieldIssue(xrpl::sfAsset, xrpl::STIssue{xrpl::sfAsset, xrpl::MPTIssue{issuanceID}}); + obj.setFieldIssue(xrpl::sfAsset2, xrpl::STIssue{xrpl::sfAsset2, xrpl::xrpIssue()}); + + auto const serialized = obj.getSerializer(); + return xrpl::STTx{xrpl::SerialIter{serialized.slice()}}; +} + +} // namespace + +struct MPTHelpersTest : virtual public ::testing::Test { +protected: + static void + verifyCommonFields( + MPTokenIssuanceTransactionsData const& data, + xrpl::STTx const& sttx, + xrpl::TxMeta const& txMeta + ) + { + EXPECT_EQ(data.accounts, txMeta.getAffectedAccounts()); + EXPECT_EQ(data.ledgerSequence, txMeta.getLgrSeq()); + EXPECT_EQ(data.transactionIndex, txMeta.getIndex()); + EXPECT_EQ(data.txHash, sttx.getTransactionID()); + } +}; + +TEST_F(MPTHelpersTest, FailedTxWithoutIssuanceReferenceProducesNoRecords) +{ + std::vector nodes; + auto const txMeta = createTxMeta(std::move(nodes), xrpl::tecINCOMPLETE); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, createTx(xrpl::ttPAYMENT)); + + EXPECT_TRUE(records.empty()); +} + +TEST_F(MPTHelpersTest, FailedTxIgnoresMPTAffectedNodes) +{ + std::vector nodes; + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, kIssuanceSeq, kIssuer)); + nodes.push_back(util::createMPTokenNode(xrpl::sfCreatedNode, defaultIssuanceID(), kAccount)); + auto const txMeta = createTxMeta(std::move(nodes), xrpl::tecINCOMPLETE); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, createTx(xrpl::ttPAYMENT)); + + EXPECT_TRUE(records.empty()); +} + +TEST_F(MPTHelpersTest, FailedTxWithTopLevelIssuanceIDProducesRecord) +{ + auto const txMeta = createTxMeta({}, xrpl::tecINCOMPLETE); + auto const sttx = createTx(xrpl::ttMPTOKEN_ISSUANCE_SET); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, FailedTxWithMPTAmountProducesRecord) +{ + auto const txMeta = createTxMeta({}, xrpl::tecINCOMPLETE); + auto const sttx = createPaymentTxWithMPTAmount(defaultIssuanceID()); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, FailedTxWithMPTIssueProducesRecord) +{ + auto const txMeta = createTxMeta({}, xrpl::tecINCOMPLETE); + auto const sttx = createAMMDepositTxWithMPTIssue(defaultIssuanceID()); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, IssuanceCreateProducesRecordWithReconstructedID) +{ + auto const tx = createMPTIssuanceCreateTxWithMetadata(kIssuer, 2, kIssuanceSeq); + xrpl::TxMeta const txMeta(xrpl::uint256(kTX), 1, tx.metadata); + auto const sttx = xrpl::STTx(xrpl::SerialIter{tx.transaction.data(), tx.transaction.size()}); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, IssuanceDestroyProducesRecordFromDeletedNode) +{ + std::vector nodes; + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfDeletedNode, kIssuanceSeq, kIssuer)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttMPTOKEN_ISSUANCE_DESTROY); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, IssuanceSetProducesRecordFromModifiedNode) +{ + std::vector nodes; + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, kIssuanceSeq, kIssuer)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttMPTOKEN_ISSUANCE_SET); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, AuthorizeProducesRecordFromMPTokenNode) +{ + auto const issuanceID = defaultIssuanceID(); + auto const tx = createMPTokenAuthorizeTxWithMetadata(kAccount, issuanceID, 2, 3); + xrpl::TxMeta const txMeta(xrpl::uint256(kTX), 1, tx.metadata); + auto const sttx = xrpl::STTx(xrpl::SerialIter{tx.transaction.data(), tx.transaction.size()}); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, issuanceID); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, DedupsAcrossMPTokenAndIssuanceNodes) +{ + // Both nodes resolve to the same issuance ID: the MPToken node carries it directly while the + // MPTokenIssuance node requires reconstruction via makeMptID. + std::vector nodes; + nodes.push_back(util::createMPTokenNode(xrpl::sfCreatedNode, defaultIssuanceID(), kAccount)); + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, kIssuanceSeq, kIssuer)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttPAYMENT); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + EXPECT_TRUE(records[0].accounts.contains(getAccountIdWithString(kAccount))); + EXPECT_TRUE(records[0].accounts.contains(getAccountIdWithString(kIssuer))); + verifyCommonFields(records[0], sttx, txMeta); +} + +TEST_F(MPTHelpersTest, MultipleIssuancesFanOutAndDedup) +{ + auto const issuanceA = xrpl::makeMptID(1, getAccountIdWithString(kIssuer)); + auto const issuanceB = xrpl::makeMptID(2, getAccountIdWithString(kIssuer)); + ASSERT_LT(issuanceA, issuanceB); + + // issuanceA is touched twice and should produce only one index record. + std::vector nodes; + nodes.push_back(util::createMPTokenNode(xrpl::sfCreatedNode, issuanceB, kAccount)); + nodes.push_back(util::createMPTokenNode(xrpl::sfModifiedNode, issuanceA, kAccount2)); + nodes.push_back(util::createMPTokenNode(xrpl::sfDeletedNode, issuanceA, kAccount)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttPAYMENT); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 2); + EXPECT_EQ(records[0].mptIssuanceID, issuanceA); + EXPECT_EQ(records[1].mptIssuanceID, issuanceB); + for (auto const& record : records) { + EXPECT_EQ(record.accounts, txMeta.getAffectedAccounts()); + verifyCommonFields(record, sttx, txMeta); + } +} + +TEST_F(MPTHelpersTest, IndexesMPTNodesRegardlessOfTransactionType) +{ + constexpr xrpl::TxType kTypes[] = { + xrpl::ttPAYMENT, xrpl::ttCLAWBACK, xrpl::ttOFFER_CREATE, xrpl::ttAMM_DEPOSIT + }; + + for (auto const type : kTypes) { + std::vector nodes; + nodes.push_back( + util::createMPTokenNode(xrpl::sfModifiedNode, defaultIssuanceID(), kAccount) + ); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(type); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1) << "TransactionType " << type; + EXPECT_EQ(records[0].mptIssuanceID, defaultIssuanceID()); + verifyCommonFields(records[0], sttx, txMeta); + } +} + +TEST_F(MPTHelpersTest, MPTNodeWithoutFieldsProducesNoRecords) +{ + // A ModifiedNode carrying no FinalFields must be skipped without crashing. + xrpl::STObject node(xrpl::sfModifiedNode); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN_ISSUANCE); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + + std::vector nodes; + nodes.push_back(std::move(node)); + auto const txMeta = createTxMeta(std::move(nodes)); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, createTx(xrpl::ttPAYMENT)); + + EXPECT_TRUE(records.empty()); +} + +TEST_F(MPTHelpersTest, MPTNodesMissingRequiredFieldsProduceNoRecords) +{ + std::vector nodes; + nodes.push_back(createMPTokenNodeWithoutIssuanceID()); + nodes.push_back(createMPTokenIssuanceNodeWithoutIssuer()); + auto const txMeta = createTxMeta(std::move(nodes)); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, createTx(xrpl::ttPAYMENT)); + + EXPECT_TRUE(records.empty()); +} + +TEST_F(MPTHelpersTest, NoMPTNodesProducesNoRecords) +{ + std::vector nodes; + nodes.push_back(createAccountRootNode(kAccount)); + auto const txMeta = createTxMeta(std::move(nodes)); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, createTx(xrpl::ttPAYMENT)); + + EXPECT_TRUE(records.empty()); +} + +TEST_F(MPTHelpersTest, RecordCarriesAllAffectedAccounts) +{ + std::vector nodes; + nodes.push_back(util::createMPTokenNode(xrpl::sfCreatedNode, defaultIssuanceID(), kAccount)); + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, kIssuanceSeq, kIssuer)); + nodes.push_back(createAccountRootNode(kAccount2)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttPAYMENT); + + auto const records = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(records.size(), 1); + EXPECT_EQ(records[0].accounts, txMeta.getAffectedAccounts()); + EXPECT_EQ(records[0].accounts.size(), 3); + EXPECT_TRUE(records[0].accounts.contains(getAccountIdWithString(kAccount))); + EXPECT_TRUE(records[0].accounts.contains(getAccountIdWithString(kAccount2))); + EXPECT_TRUE(records[0].accounts.contains(getAccountIdWithString(kIssuer))); +} + +TEST_F(MPTHelpersTest, ExtractionIsDeterministic) +{ + auto const issuanceA = xrpl::makeMptID(1, getAccountIdWithString(kIssuer)); + + std::vector nodes; + nodes.push_back(util::createMPTokenNode(xrpl::sfCreatedNode, issuanceA, kAccount)); + nodes.push_back(util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, kIssuanceSeq, kIssuer)); + auto const txMeta = createTxMeta(std::move(nodes)); + auto const sttx = createTx(xrpl::ttPAYMENT); + + auto const first = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + auto const second = etl::getMPTokenIssuanceTxsFromTx(txMeta, sttx); + + ASSERT_EQ(first.size(), 2); + ASSERT_EQ(first.size(), second.size()); + for (std::size_t i = 0; i < first.size(); ++i) { + EXPECT_EQ(first[i].mptIssuanceID, second[i].mptIssuanceID); + EXPECT_EQ(first[i].accounts, second[i].accounts); + EXPECT_EQ(first[i].ledgerSequence, second[i].ledgerSequence); + EXPECT_EQ(first[i].transactionIndex, second[i].transactionIndex); + EXPECT_EQ(first[i].txHash, second[i].txHash); + } +} diff --git a/tests/unit/etl/ext/MPTTests.cpp b/tests/unit/etl/ext/MPTTests.cpp index 79863e697..4abd5053b 100644 --- a/tests/unit/etl/ext/MPTTests.cpp +++ b/tests/unit/etl/ext/MPTTests.cpp @@ -3,15 +3,21 @@ #include "etl/impl/ext/MPT.hpp" #include "rpc/RPCHelpers.hpp" #include "util/BinaryTestObject.hpp" +#include "util/MPTokenTestObjects.hpp" #include "util/MockBackendTestFixture.hpp" #include "util/MockPrometheus.hpp" #include "util/TestObject.hpp" #include #include +#include +#include #include +#include +#include #include #include +#include #include #include #include @@ -21,8 +27,10 @@ #include #include +#include +#include +#include #include -#include #include #include @@ -68,6 +76,125 @@ constinit auto const kHash = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2A constinit auto const kHash2 = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2AEBB3AF7654261C"; constinit auto const kHash3 = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2AEBB3AF7654261D"; +// The issuance ID carried by the ltMPTOKEN CreatedNode in kTxnMeta. +constinit auto const kIssuanceID = "002DBD1817E0AF9FDE4F9978B8FCD8A5063630B5737DA605"; + +constinit auto const kAccount = "rM2AGCCCRb373FRuD8wHyUwUsh2dV4BW5Q"; +constinit auto const kAccount2 = "rnd1nHuzceyQDqnLH8urWNr4QBKt4v7WVk"; +constexpr auto kHighFanoutAccountCount = 1001u; + +xrpl::AccountID +accountIDFromSeed(std::uint32_t seed) +{ + std::array bytes{}; + bytes[16] = static_cast(seed >> 24); + bytes[17] = static_cast(seed >> 16); + bytes[18] = static_cast(seed >> 8); + bytes[19] = static_cast(seed); + return xrpl::AccountID::fromVoid(bytes.data()); +} + +xrpl::STObject +createAccountRootNode(xrpl::AccountID const& account) +{ + xrpl::STObject fields(xrpl::sfFinalFields); + fields.setAccountID(xrpl::sfAccount, account); + + xrpl::STObject node(xrpl::sfModifiedNode); + node.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltACCOUNT_ROOT); + node.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); + node.set(std::move(fields)); + return node; +} + +// One Payment transaction touching two distinct issuances with three affected accounts. +etl::model::Transaction +createMultiIssuanceTransaction() +{ + xrpl::Slice const slice("test", 4); + xrpl::STObject tx(xrpl::sfTransaction); + tx.setFieldU16(xrpl::sfTransactionType, xrpl::ttPAYMENT); + tx.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + tx.setFieldAmount(xrpl::sfAmount, xrpl::STAmount(100, false)); + tx.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10, false)); + tx.setAccountID(xrpl::sfDestination, getAccountIdWithString(kAccount2)); + tx.setFieldU32(xrpl::sfSequence, 1); + tx.setFieldVL(xrpl::sfSigningPubKey, slice); + + auto const serialized = tx.getSerializer(); + auto const sttx = xrpl::STTx{xrpl::SerialIter{serialized.slice()}}; + + auto const issuanceA = xrpl::makeMptID(1, getAccountIdWithString(kHolderAccount)); + auto const issuanceB = xrpl::makeMptID(2, getAccountIdWithString(kHolderAccount)); + + xrpl::STObject metaObj(xrpl::sfTransactionMetaData); + metaObj.setFieldU8(xrpl::sfTransactionResult, xrpl::tesSUCCESS); + metaObj.setFieldU32(xrpl::sfTransactionIndex, 0); + + xrpl::STArray affectedNodes(xrpl::sfAffectedNodes); + affectedNodes.push_back(util::createMPTokenNode(xrpl::sfModifiedNode, issuanceA, kAccount)); + affectedNodes.push_back(util::createMPTokenNode(xrpl::sfModifiedNode, issuanceB, kAccount2)); + affectedNodes.push_back( + util::createMPTokenIssuanceNode(xrpl::sfModifiedNode, 1, kHolderAccount) + ); // issuanceA again + metaObj.setFieldArray(xrpl::sfAffectedNodes, affectedNodes); + + auto const txMeta = + xrpl::TxMeta{sttx.getTransactionID(), kSeq, metaObj.getSerializer().peekData()}; + + return etl::model::Transaction{ + .raw = "", + .metaRaw = "", + .sttx = sttx, + .meta = txMeta, + .id = sttx.getTransactionID(), + .key = "0000000000000000000000000000000000000000000000000000000000000002", + .type = sttx.getTxnType() + }; +} + +etl::model::Transaction +createHighFanoutIssuanceTransaction() +{ + xrpl::Slice const slice("test", 4); + xrpl::STObject tx(xrpl::sfTransaction); + tx.setFieldU16(xrpl::sfTransactionType, xrpl::ttPAYMENT); + tx.setAccountID(xrpl::sfAccount, getAccountIdWithString(kAccount)); + tx.setFieldAmount(xrpl::sfAmount, xrpl::STAmount(100, false)); + tx.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10, false)); + tx.setAccountID(xrpl::sfDestination, getAccountIdWithString(kAccount2)); + tx.setFieldU32(xrpl::sfSequence, 1); + tx.setFieldVL(xrpl::sfSigningPubKey, slice); + + auto const serialized = tx.getSerializer(); + auto const sttx = xrpl::STTx{xrpl::SerialIter{serialized.slice()}}; + + xrpl::STObject metaObj(xrpl::sfTransactionMetaData); + metaObj.setFieldU8(xrpl::sfTransactionResult, xrpl::tesSUCCESS); + metaObj.setFieldU32(xrpl::sfTransactionIndex, 0); + + xrpl::STArray affectedNodes(xrpl::sfAffectedNodes); + affectedNodes.push_back( + util::createMPTokenNode(xrpl::sfModifiedNode, xrpl::uint192{kMptIssuanceID}, kHolderAccount) + ); + for (std::uint32_t i = 0; i < kHighFanoutAccountCount; ++i) + affectedNodes.push_back(createAccountRootNode(accountIDFromSeed(i + 1))); + metaObj.setFieldArray(xrpl::sfAffectedNodes, affectedNodes); + + auto const txMeta = + xrpl::TxMeta{sttx.getTransactionID(), kSeq, metaObj.getSerializer().peekData()}; + + return etl::model::Transaction{ + .raw = "", + .metaRaw = "", + .sttx = sttx, + .meta = txMeta, + .id = sttx.getTransactionID(), + .key = "0000000000000000000000000000000000000000000000000000000000000003", + .type = sttx.getTxnType() + }; +} + auto createTransactionFromObjects( xrpl::STObject const& txObj, @@ -92,21 +219,6 @@ createTransactionFromObjects( }; } -xrpl::STObject -createNewMPTokenNode(std::string_view holder) -{ - xrpl::STObject newFields(xrpl::sfNewFields); - newFields.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN); - newFields[xrpl::sfMPTokenIssuanceID] = xrpl::uint192{kMptIssuanceID}; - newFields.setAccountID(xrpl::sfAccount, getAccountIdWithString(holder)); - - xrpl::STObject createdNode(xrpl::sfCreatedNode); - createdNode.setFieldU16(xrpl::sfLedgerEntryType, xrpl::ltMPTOKEN); - createdNode.setFieldH256(xrpl::sfLedgerIndex, xrpl::uint256{}); - createdNode.set(std::move(newFields)); - return createdNode; -} - xrpl::STObject createPaymentMetaWithNewMPTokens(xrpl::TER result = xrpl::tesSUCCESS) { @@ -115,8 +227,12 @@ createPaymentMetaWithNewMPTokens(xrpl::TER result = xrpl::tesSUCCESS) metaObj.setFieldU32(xrpl::sfTransactionIndex, 0); xrpl::STArray affectedNodes(xrpl::sfAffectedNodes); - affectedNodes.push_back(createNewMPTokenNode(kHolderAccount)); - affectedNodes.push_back(createNewMPTokenNode(kHolderAccount2)); + affectedNodes.push_back( + util::createMPTokenNode(xrpl::sfCreatedNode, xrpl::uint192{kMptIssuanceID}, kHolderAccount) + ); + affectedNodes.push_back( + util::createMPTokenNode(xrpl::sfCreatedNode, xrpl::uint192{kMptIssuanceID}, kHolderAccount2) + ); metaObj.setFieldArray(xrpl::sfAffectedNodes, affectedNodes); return metaObj; @@ -168,6 +284,7 @@ createTestDataWithoutMPToken() auto createTestData() { + // Only the AUTHORIZE transaction carries metadata with MPT affected nodes. auto transactions = std::vector{ util::createTransaction( xrpl::TxType::ttMPTOKEN_ISSUANCE_CREATE @@ -191,13 +308,33 @@ createTestData() }; } +// Same AUTHORIZE fixture as kTxnMeta, with a distinct transaction index. +etl::model::Transaction +createAuthorizeTransactionWithIndex(std::string const& hashStr, std::uint32_t txIndex) +{ + auto tx = + util::createTransaction(xrpl::TxType::ttMPTOKEN_AUTHORIZE, hashStr, kTxnMeta, kTxnHex); + + auto const metaBlob = xrpl::strUnHex(kTxnMeta); + EXPECT_TRUE(metaBlob.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + xrpl::SerialIter sitMeta{xrpl::makeSlice(*metaBlob)}; + xrpl::STObject metaObj{sitMeta, xrpl::sfMetadata}; + metaObj.setFieldU32(xrpl::sfTransactionIndex, txIndex); + + xrpl::uint256 hash; + EXPECT_TRUE(hash.parseHex(hashStr)); + tx.meta = xrpl::TxMeta{hash, kSeq, metaObj.getSerializer().peekData()}; + return tx; +} + auto createMultipleHoldersTestData() { auto transactions = std::vector{ - util::createTransaction(xrpl::TxType::ttMPTOKEN_AUTHORIZE, kHash, kTxnMeta, kTxnHex), - util::createTransaction(xrpl::TxType::ttMPTOKEN_AUTHORIZE, kHash2, kTxnMeta, kTxnHex), - util::createTransaction(xrpl::TxType::ttMPTOKEN_AUTHORIZE, kHash3, kTxnMeta, kTxnHex) + createAuthorizeTransactionWithIndex(kHash, 0), + createAuthorizeTransactionWithIndex(kHash2, 1), + createAuthorizeTransactionWithIndex(kHash3, 2) }; auto const header = createLedgerHeader(kLedgerHash, kSeq); @@ -227,7 +364,20 @@ TEST_F(MPTExtTests, OnLedgerDataFiltersAndWritesMPTs) EXPECT_EQ(holders.size(), 1); // Only metadata creating an MPToken is written }); + std::vector issuanceTxs; + std::vector accountIssuanceTxs; + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).WillOnce(SaveArg<0>(&issuanceTxs)); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions) + .WillOnce(SaveArg<0>(&accountIssuanceTxs)); + ext_.onLedgerData(data); + + // Only the AUTHORIZE fixture touches an MPT object. + ASSERT_EQ(issuanceTxs.size(), 1); + EXPECT_EQ(issuanceTxs[0].mptIssuanceID, xrpl::uint192(kIssuanceID)); + EXPECT_FALSE(issuanceTxs[0].accounts.empty()); + EXPECT_TRUE(issuanceTxs[0].accounts.contains(getAccountIdWithString(kHolderAccount))); + EXPECT_EQ(issuanceTxs, accountIssuanceTxs); // same vector goes to both tables } TEST_F(MPTExtTests, OnInitialDataFiltersAndWritesMPTs) @@ -238,7 +388,20 @@ TEST_F(MPTExtTests, OnInitialDataFiltersAndWritesMPTs) EXPECT_EQ(holders.size(), 1); // Only metadata creating an MPToken is written }); + std::vector issuanceTxs; + std::vector accountIssuanceTxs; + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).WillOnce(SaveArg<0>(&issuanceTxs)); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions) + .WillOnce(SaveArg<0>(&accountIssuanceTxs)); + ext_.onInitialData(data); + + // Only the AUTHORIZE fixture touches an MPT object. + ASSERT_EQ(issuanceTxs.size(), 1); + EXPECT_EQ(issuanceTxs[0].mptIssuanceID, xrpl::uint192(kIssuanceID)); + EXPECT_FALSE(issuanceTxs[0].accounts.empty()); + EXPECT_TRUE(issuanceTxs[0].accounts.contains(getAccountIdWithString(kHolderAccount))); + EXPECT_EQ(issuanceTxs, accountIssuanceTxs); // same vector goes to both tables } TEST_F(MPTExtTests, OnInitialObjectWritesMPT) @@ -257,16 +420,131 @@ TEST_F(MPTExtTests, OnInitialDataWithMultipleHolders) auto const data = createMultipleHoldersTestData(); EXPECT_CALL(*backend_, writeMPTHolders).WillOnce([](auto const& holders) { - EXPECT_EQ(holders.size(), 3); // Expect all three AUTHORIZE transactions + EXPECT_EQ(holders.size(), 3); // All three AUTHORIZE transactions auto const expectedAccount = - rpc::accountFromStringStrict(kHolderAccount); // Expect all three to be the same + rpc::accountFromStringStrict(kHolderAccount); // Same holder in each fixture EXPECT_TRUE(std::ranges::all_of(holders, [&expectedAccount](auto const& data) { return data.holder == expectedAccount; })); }); + std::vector issuanceTxs; + std::vector accountIssuanceTxs; + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).WillOnce(SaveArg<0>(&issuanceTxs)); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions) + .WillOnce(SaveArg<0>(&accountIssuanceTxs)); + ext_.onInitialData(data); + + // One record per AUTHORIZE transaction; each has a distinct transaction index. + ASSERT_EQ(issuanceTxs.size(), 3); + EXPECT_TRUE(std::ranges::all_of(issuanceTxs, [](auto const& record) { + return record.mptIssuanceID == xrpl::uint192(kIssuanceID); + })); + std::vector indices; + std::ranges::transform(issuanceTxs, std::back_inserter(indices), [](auto const& record) { + return record.transactionIndex; + }); + EXPECT_THAT(indices, UnorderedElementsAre(0, 1, 2)); + EXPECT_EQ(issuanceTxs, accountIssuanceTxs); +} + +TEST_F(MPTExtTests, NoMPTTransactionsWritesNothing) +{ + auto transactions = std::vector{ + util::createTransaction(xrpl::TxType::ttAMM_CREATE), + util::createTransaction(xrpl::TxType::ttAMM_CREATE) + }; + + auto const header = createLedgerHeader(kLedgerHash, kSeq); + auto const data = etl::model::LedgerData{ + .transactions = std::move(transactions), + .objects = {}, + .successors = {}, + .edgeKeys = {}, + .header = header, + .rawHeader = {}, + .seq = kSeq + }; + + EXPECT_CALL(*backend_, writeMPTHolders).Times(0); + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).Times(0); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions).Times(0); + + ext_.onLedgerData(data); +} + +TEST_F(MPTExtTests, OnLedgerDataDedupsMultiIssuanceFanout) +{ + auto transactions = std::vector{createMultiIssuanceTransaction()}; + + auto const header = createLedgerHeader(kLedgerHash, kSeq); + auto const data = etl::model::LedgerData{ + .transactions = std::move(transactions), + .objects = {}, + .successors = {}, + .edgeKeys = {}, + .header = header, + .rawHeader = {}, + .seq = kSeq + }; + + std::vector issuanceTxs; + std::vector accountIssuanceTxs; + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).WillOnce(SaveArg<0>(&issuanceTxs)); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions) + .WillOnce(SaveArg<0>(&accountIssuanceTxs)); + + ext_.onLedgerData(data); + + // issuanceA is touched twice, issuanceB once; each record carries the full account set. + auto const issuanceA = xrpl::makeMptID(1, getAccountIdWithString(kHolderAccount)); + auto const issuanceB = xrpl::makeMptID(2, getAccountIdWithString(kHolderAccount)); + + ASSERT_EQ(issuanceTxs.size(), 2); + EXPECT_EQ(issuanceTxs[0].mptIssuanceID, issuanceA); + EXPECT_EQ(issuanceTxs[1].mptIssuanceID, issuanceB); + for (auto const& record : issuanceTxs) { + EXPECT_EQ(record.accounts.size(), 3); + EXPECT_TRUE(record.accounts.contains(getAccountIdWithString(kAccount))); + EXPECT_TRUE(record.accounts.contains(getAccountIdWithString(kAccount2))); + EXPECT_TRUE(record.accounts.contains(getAccountIdWithString(kHolderAccount))); + EXPECT_EQ(record.ledgerSequence, kSeq); + } + EXPECT_EQ(issuanceTxs, accountIssuanceTxs); +} + +TEST_F(MPTExtTests, OnLedgerDataWritesHighFanoutIssuanceIndexWithoutHolders) +{ + auto transactions = std::vector{createHighFanoutIssuanceTransaction()}; + + auto const header = createLedgerHeader(kLedgerHash, kSeq); + auto const data = etl::model::LedgerData{ + .transactions = std::move(transactions), + .objects = {}, + .successors = {}, + .edgeKeys = {}, + .header = header, + .rawHeader = {}, + .seq = kSeq + }; + + EXPECT_CALL(*backend_, writeMPTHolders).Times(0); + + std::vector issuanceTxs; + std::vector accountIssuanceTxs; + EXPECT_CALL(*backend_, writeMPTokenIssuanceTransactions).WillOnce(SaveArg<0>(&issuanceTxs)); + EXPECT_CALL(*backend_, writeAccountMPTokenIssuanceTransactions) + .WillOnce(SaveArg<0>(&accountIssuanceTxs)); + + ext_.onLedgerData(data); + + ASSERT_EQ(issuanceTxs.size(), 1); + EXPECT_EQ(issuanceTxs[0].mptIssuanceID, xrpl::uint192{kMptIssuanceID}); + EXPECT_GT(issuanceTxs[0].accounts.size(), kHighFanoutAccountCount); + EXPECT_EQ(issuanceTxs[0].ledgerSequence, kSeq); + EXPECT_EQ(issuanceTxs, accountIssuanceTxs); } TEST_F(MPTExtTests, OnInitialDataDoesNotWriteFailedMPTokenCreations)