feat: Update MPToken ETL to support all transactions types (#3102)

## Summary

Fixes incomplete MPT holder indexing in ETL.

Previously, Clio only indexed MPT holders when an `ltMPTOKEN` ledger
node was created by an `MPTokenAuthorize` transaction. However, with
future amendments, XRPL can also create `ltMPTOKEN` nodes as part of
other successful transactions, such as `Payment`, when a holder receives
an MPT. Those holders were silently skipped and therefore missing from
the `mpt_holders` RPC results.

This change makes MPT holder indexing metadata-driven instead of
transaction-type-driven. ETL now scans successful transaction metadata
for newly created `ltMPTOKEN` nodes and writes every holder it finds.

## What Changed

- MPT holder extraction now works for any successful transaction that
creates an `ltMPTOKEN`.
- Multiple MPT holders created in a single transaction are all indexed.
- The `mpt_holders` table is kept consistent with ledger metadata
instead of relying on assumptions about which transaction types can
create holder nodes.
- Added regression coverage for MPT holders created by a `Payment`
transaction.

## Testing

- `build/clio_tests '--gtest_filter=MPTExtTests.*'`
- `clang-tidy -p build src/etl/MPTHelpers.cpp src/etl/MPTHelpers.hpp
src/etl/impl/ext/MPT.cpp tests/unit/etl/ext/MPTTests.cpp`
This commit is contained in:
Shawn Xie
2026-06-15 06:46:55 -04:00
committed by GitHub
parent b8ac68c6d5
commit 0aa7ed4919
4 changed files with 184 additions and 32 deletions

View File

@@ -11,45 +11,37 @@
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <optional>
#include <string>
#include <vector>
namespace etl {
/**
* @brief Get the MPToken created from a transaction
*
* @param txMeta Transaction metadata
* @return MPT and holder account pair
*/
std::optional<MPTHolderData>
getMPTokenAuthorize(ripple::TxMeta const& txMeta)
std::vector<MPTHolderData>
getMPTHolderFromTx(ripple::TxMeta const& txMeta, ripple::STTx const&)
{
if (txMeta.getResultTER() != ripple::tesSUCCESS)
return {};
std::vector<MPTHolderData> holders;
for (ripple::STObject const& node : txMeta.getNodes()) {
if (node.getFieldU16(ripple::sfLedgerEntryType) != ripple::ltMPTOKEN)
continue;
if (node.getFName() == ripple::sfCreatedNode) {
auto const& newMPT = node.peekAtField(ripple::sfNewFields).downcast<ripple::STObject>();
return MPTHolderData{
.mptID = newMPT[ripple::sfMPTokenIssuanceID],
.holder = newMPT.getAccountID(ripple::sfAccount)
};
holders.push_back(
MPTHolderData{
.mptID = newMPT[ripple::sfMPTokenIssuanceID],
.holder = newMPT.getAccountID(ripple::sfAccount)
}
);
}
}
return {};
}
std::optional<MPTHolderData>
getMPTHolderFromTx(ripple::TxMeta const& txMeta, ripple::STTx const& sttx)
{
if (txMeta.getResultTER() != ripple::tesSUCCESS ||
sttx.getTxnType() != ripple::TxType::ttMPTOKEN_AUTHORIZE)
return {};
return getMPTokenAuthorize(txMeta);
return holders;
}
std::optional<MPTHolderData>

View File

@@ -6,6 +6,10 @@
#include <ripple/protocol/STTx.h>
#include <ripple/protocol/TxMeta.h>
#include <optional>
#include <string>
#include <vector>
namespace etl {
/**
@@ -13,9 +17,10 @@ namespace etl {
*
* @param txMeta Transaction metadata
* @param sttx The transaction
* @return The MPTIssuanceID and holder pair as a optional
* @return The MPTIssuanceID and holder pairs created by the transaction; empty if the transaction
* failed or created no MPToken.
*/
std::optional<MPTHolderData>
std::vector<MPTHolderData>
getMPTHolderFromTx(ripple::TxMeta const& txMeta, ripple::STTx const& sttx);
/**

View File

@@ -49,8 +49,8 @@ MPTExt::writeMPTHoldersFromTransactions(model::LedgerData const& data)
std::vector<MPTHolderData> holders;
for (auto const& tx : data.transactions) {
if (auto const mptHolder = getMPTHolderFromTx(tx.meta, tx.sttx); mptHolder.has_value())
holders.push_back(*mptHolder);
auto const mptHolders = getMPTHolderFromTx(tx.meta, tx.sttx);
holders.append_range(mptHolders);
}
if (not holders.empty())

View File

@@ -1,3 +1,4 @@
#include "data/DBHelpers.hpp"
#include "etl/Models.hpp"
#include "etl/impl/ext/MPT.hpp"
#include "rpc/RPCHelpers.hpp"
@@ -8,9 +9,20 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxMeta.h>
#include <algorithm>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
@@ -25,6 +37,8 @@ constinit auto const kSeq = 123u;
constinit auto const kLedgerHash =
"4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652";
constinit auto const kHolderAccount = "rK1EX542EgA9m948JrJRaEzwLVEhqWvnr9";
constinit auto const kHolderAccount2 = "rnd1nHuzceyQDqnLH8urWNr4QBKt4v7WVk";
constinit auto const kMptIssuanceID = "000004C463C52827307480341125DA0577DEFC38405B0E3E";
constinit auto const kTxnHex =
"120039220000000024002DBD1A201B002DBDA36840000000000000017321EDECF25C029811CAD07AFD616EB75E3803"
@@ -54,18 +68,115 @@ constinit auto const kHash = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2A
constinit auto const kHash2 = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2AEBB3AF7654261C";
constinit auto const kHash3 = "6005B465CBBF7FA8E41AC0C0CD38491026D9411FCB7BA46E2AEBB3AF7654261D";
auto
createTransactionFromObjects(
ripple::STObject const& txObj,
ripple::STObject const& metaObj,
ripple::TxType type
)
{
auto const txBlob = txObj.getSerializer().peekData();
auto const metaBlob = metaObj.getSerializer().peekData();
ripple::SerialIter txIter{txBlob.data(), txBlob.size()};
ripple::uint256 const id{kHash};
return etl::model::Transaction{
.raw = "",
.metaRaw = "",
.sttx = ripple::STTx{txIter},
.meta = ripple::TxMeta{id, kSeq, metaBlob},
.id = id,
.key = std::string{kHash},
.type = type
};
}
ripple::STObject
createNewMPTokenNode(std::string_view holder)
{
ripple::STObject newFields(ripple::sfNewFields);
newFields.setFieldU16(ripple::sfLedgerEntryType, ripple::ltMPTOKEN);
newFields[ripple::sfMPTokenIssuanceID] = ripple::uint192{kMptIssuanceID};
newFields.setAccountID(ripple::sfAccount, getAccountIdWithString(holder));
ripple::STObject createdNode(ripple::sfCreatedNode);
createdNode.setFieldU16(ripple::sfLedgerEntryType, ripple::ltMPTOKEN);
createdNode.setFieldH256(ripple::sfLedgerIndex, ripple::uint256{});
createdNode.emplace_back(std::move(newFields));
return createdNode;
}
ripple::STObject
createPaymentMetaWithNewMPTokens(ripple::TER result = ripple::tesSUCCESS)
{
ripple::STObject metaObj(ripple::sfTransactionMetaData);
metaObj.setFieldU8(ripple::sfTransactionResult, TERtoInt(result));
metaObj.setFieldU32(ripple::sfTransactionIndex, 0);
ripple::STArray affectedNodes(ripple::sfAffectedNodes);
affectedNodes.push_back(createNewMPTokenNode(kHolderAccount));
affectedNodes.push_back(createNewMPTokenNode(kHolderAccount2));
metaObj.setFieldArray(ripple::sfAffectedNodes, affectedNodes);
return metaObj;
}
auto
createPaymentWithMultipleHoldersTestData(ripple::TER result = ripple::tesSUCCESS)
{
auto transactions = std::vector{createTransactionFromObjects(
createPaymentTransactionObject(kHolderAccount, kHolderAccount2, 1, 1, 1),
createPaymentMetaWithNewMPTokens(result),
ripple::TxType::ttPAYMENT
)};
auto const header = createLedgerHeader(kLedgerHash, kSeq);
return etl::model::LedgerData{
.transactions = std::move(transactions),
.objects = {},
.successors = {},
.edgeKeys = {},
.header = header,
.rawHeader = {},
.seq = kSeq
};
}
auto
createTestDataWithoutMPToken()
{
auto transactions = std::vector{
util::createTransaction(
ripple::TxType::ttMPTOKEN_ISSUANCE_CREATE
), // metadata does not create an MPT holder
util::createTransaction(ripple::TxType::ttAMM_CREATE), // metadata is not MPT
};
auto const header = createLedgerHeader(kLedgerHash, kSeq);
return etl::model::LedgerData{
.transactions = std::move(transactions),
.objects = {},
.successors = {},
.edgeKeys = {},
.header = header,
.rawHeader = {},
.seq = kSeq
};
}
auto
createTestData()
{
auto transactions = std::vector{
util::createTransaction(
ripple::TxType::ttMPTOKEN_ISSUANCE_CREATE
), // not AUTHORIZE so will not be written
), // metadata does not create an MPT holder
util::createTransaction(ripple::TxType::ttMPTOKEN_AUTHORIZE, kHash, kTxnMeta, kTxnHex),
util::createTransaction(ripple::TxType::ttAMM_CREATE), // not MPT - will be filtered
util::createTransaction(ripple::TxType::ttAMM_CREATE), // metadata is not MPT
util::createTransaction(
ripple::TxType::ttMPTOKEN_ISSUANCE_CREATE
), // not unique - will be filtered
), // metadata does not create an MPT holder
};
auto const header = createLedgerHeader(kLedgerHash, kSeq);
@@ -113,7 +224,7 @@ TEST_F(MPTExtTests, OnLedgerDataFiltersAndWritesMPTs)
auto const data = createTestData();
EXPECT_CALL(*backend_, writeMPTHolders).WillOnce([](auto const& holders) {
EXPECT_EQ(holders.size(), 1); // Only AUTHORIZE is written in the end
EXPECT_EQ(holders.size(), 1); // Only metadata creating an MPToken is written
});
ext_.onLedgerData(data);
@@ -124,7 +235,7 @@ TEST_F(MPTExtTests, OnInitialDataFiltersAndWritesMPTs)
auto const data = createTestData();
EXPECT_CALL(*backend_, writeMPTHolders).WillOnce([](auto const& holders) {
EXPECT_EQ(holders.size(), 1); // Only AUTHORIZE is written in the end
EXPECT_EQ(holders.size(), 1); // Only metadata creating an MPToken is written
});
ext_.onInitialData(data);
@@ -157,3 +268,47 @@ TEST_F(MPTExtTests, OnInitialDataWithMultipleHolders)
ext_.onInitialData(data);
}
TEST_F(MPTExtTests, OnInitialDataDoesNotWriteFailedMPTokenCreations)
{
auto const data = createPaymentWithMultipleHoldersTestData(ripple::tecINCOMPLETE);
EXPECT_CALL(*backend_, writeMPTHolders).Times(0);
ext_.onInitialData(data);
}
TEST_F(MPTExtTests, OnInitialDataDoesNotWriteWithoutCreatedMPToken)
{
auto const data = createTestDataWithoutMPToken();
EXPECT_CALL(*backend_, writeMPTHolders).Times(0);
ext_.onInitialData(data);
}
TEST_F(MPTExtTests, OnInitialDataWritesAllMPTsCreatedByPayment)
{
auto const data = createPaymentWithMultipleHoldersTestData();
auto const expectedMptID = ripple::uint192{kMptIssuanceID};
auto const expectedAccount = getAccountIdWithString(kHolderAccount);
auto const expectedAccount2 = getAccountIdWithString(kHolderAccount2);
EXPECT_CALL(*backend_, writeMPTHolders).WillOnce([&](auto const& holders) {
EXPECT_THAT(
holders,
UnorderedElementsAre(
AllOf(
Field(&MPTHolderData::mptID, expectedMptID),
Field(&MPTHolderData::holder, expectedAccount)
),
AllOf(
Field(&MPTHolderData::mptID, expectedMptID),
Field(&MPTHolderData::holder, expectedAccount2)
)
)
);
});
ext_.onInitialData(data);
}