From 466b60f8eac94133e2eb5808303e8891843e08a6 Mon Sep 17 00:00:00 2001 From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:22:43 -0400 Subject: [PATCH] feat: Support filtering txns by mpt_issuance_id (#3153) Adds an optional `mpt_issuance_id ` field to Account_tx so that users can filter Account transactions if the transaction is involved in a specific `mpt_issuance_id ` --- src/etl/MPTHelpers.cpp | 19 ++ src/etl/MPTHelpers.hpp | 20 ++ src/rpc/CMakeLists.txt | 2 +- src/rpc/handlers/AccountTx.cpp | 19 ++ src/rpc/handlers/AccountTx.hpp | 2 + src/rpc/handlers/MPTHolders.cpp | 1 + tests/unit/rpc/handlers/AccountTxTests.cpp | 303 +++++++++++++++++++++ 7 files changed, 365 insertions(+), 1 deletion(-) diff --git a/src/etl/MPTHelpers.cpp b/src/etl/MPTHelpers.cpp index 975dc6b7f..11471c75f 100644 --- a/src/etl/MPTHelpers.cpp +++ b/src/etl/MPTHelpers.cpp @@ -155,6 +155,25 @@ getMPTokenIssuanceTxsFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const& sttx) return result; } +bool +referencesMptIssuance( + xrpl::TxMeta const& txMeta, + xrpl::STTx const& sttx, + xrpl::uint192 const& mptIssuanceID +) +{ + if (txMeta.getResultTER() == xrpl::tesSUCCESS) { + for (auto const& node : txMeta.getNodes()) { + if (getMPTokenIssuanceIDFromNode(node) == mptIssuanceID) + return true; + } + } + + MPTokenIssuanceIDs issuanceIDs; + addMPTokenIssuanceIDsFromTx(issuanceIDs, sttx); + return issuanceIDs.contains(mptIssuanceID); +} + std::optional getMPTHolderFromObj(std::string const& key, std::string const& blob) { diff --git a/src/etl/MPTHelpers.hpp b/src/etl/MPTHelpers.hpp index cdc3405ad..69241df80 100644 --- a/src/etl/MPTHelpers.hpp +++ b/src/etl/MPTHelpers.hpp @@ -3,6 +3,7 @@ #include "data/DBHelpers.hpp" +#include #include #include @@ -51,4 +52,23 @@ getMPTHolderFromObj(std::string const& key, std::string const& blob); std::vector getMPTokenIssuanceTxsFromTx(xrpl::TxMeta const& txMeta, xrpl::STTx const& sttx); +/** + * @brief Check whether a transaction references a specific MPT issuance. + * + * @note Scans the same sources as getMPTokenIssuanceTxsFromTx (metadata's affected + * MPTokenIssuance/MPToken nodes, and the transaction's own MPTokenIssuanceID/MPT issue fields), but + * exits as soon as a match is found instead of collecting every distinct issuance touched. + * + * @param txMeta Transaction metadata. + * @param sttx The transaction. + * @param mptIssuanceID The MPT issuance to check for. + * @return true if the transaction references mptIssuanceID. + */ +bool +referencesMptIssuance( + xrpl::TxMeta const& txMeta, + xrpl::STTx const& sttx, + xrpl::uint192 const& mptIssuanceID +); + } // namespace etl diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 9691de44d..b427817b1 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -58,4 +58,4 @@ target_sources( handlers/VaultInfo.cpp ) -target_link_libraries(clio_rpc PUBLIC clio_util clio_data) +target_link_libraries(clio_rpc PUBLIC clio_util clio_data clio_etl) diff --git a/src/rpc/handlers/AccountTx.cpp b/src/rpc/handlers/AccountTx.cpp index 720529438..154741822 100644 --- a/src/rpc/handlers/AccountTx.cpp +++ b/src/rpc/handlers/AccountTx.cpp @@ -1,6 +1,7 @@ #include "rpc/handlers/AccountTx.hpp" #include "data/Types.hpp" +#include "etl/MPTHelpers.hpp" #include "rpc/Errors.hpp" #include "rpc/JS.hpp" #include "rpc/RPCHelpers.hpp" @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -128,6 +130,10 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c if (retCursor) response.marker = {.ledger = retCursor->ledgerSequence, .seq = retCursor->transactionIndex}; + std::optional mptIssuanceFilter; + if (input.mptIssuanceId) + mptIssuanceFilter = xrpl::uint192{input.mptIssuanceId->c_str()}; + for (auto const& txnPlusMeta : blobs) { // over the range if ((txnPlusMeta.ledgerSequence < minIndex && !input.forward) || @@ -142,6 +148,14 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c boost::json::object obj; + // Skip all Txns where the specified filter mpt_id doesn't match the query + if (mptIssuanceFilter) { + auto const [sttx, txMeta] = + deserializeTxPlusMeta(txnPlusMeta, txnPlusMeta.ledgerSequence); + if (!etl::referencesMptIssuance(*txMeta, *sttx, *mptIssuanceFilter)) + continue; + } + // if binary is false or transactionType is specified, we need to expand the transaction if (!input.binary || input.transactionTypeInLowercase.has_value()) { auto [txn, meta] = toExpandedJson(txnPlusMeta, ctx.apiVersion, NFTokenjson::ENABLE); @@ -298,6 +312,11 @@ tag_invoke(boost::json::value_to_tag, boost::json::valu boost::json::value_to(jsonObject.at("tx_type")); } + if (jsonObject.contains(JS(mpt_issuance_id))) { + input.mptIssuanceId = + boost::json::value_to(jsonObject.at(JS(mpt_issuance_id))); + } + return input; } diff --git a/src/rpc/handlers/AccountTx.hpp b/src/rpc/handlers/AccountTx.hpp index d21cf5814..3dcd6dc9e 100644 --- a/src/rpc/handlers/AccountTx.hpp +++ b/src/rpc/handlers/AccountTx.hpp @@ -85,6 +85,7 @@ public: std::optional limit; std::optional marker; std::optional transactionTypeInLowercase; + std::optional mptIssuanceId; }; using Result = HandlerReturnType; @@ -141,6 +142,7 @@ public: typesKeysInLowercase.cbegin(), typesKeysInLowercase.cend() ), }, + {JS(mpt_issuance_id), validation::CustomValidators::uint192HexStringValidator}, }; static auto const kRpcSpec = RpcSpec{ diff --git a/src/rpc/handlers/MPTHolders.cpp b/src/rpc/handlers/MPTHolders.cpp index ba67302ca..247f7a815 100644 --- a/src/rpc/handlers/MPTHolders.cpp +++ b/src/rpc/handlers/MPTHolders.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include diff --git a/tests/unit/rpc/handlers/AccountTxTests.cpp b/tests/unit/rpc/handlers/AccountTxTests.cpp index 7709d08ce..1c2b3522c 100644 --- a/tests/unit/rpc/handlers/AccountTxTests.cpp +++ b/tests/unit/rpc/handlers/AccountTxTests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -35,6 +36,8 @@ constexpr auto kLedgerHash = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25 constexpr auto kNftId = "05FB0EB4B899F056FA095537C5817163801F544BAFCEA39C995D76DB4D16F9DF"; constexpr auto kNftID2 = "05FB0EB4B899F056FA095537C5817163801F544BAFCEA39C995D76DB4D16F9DA"; constexpr auto kNftID3 = "15FB0EB4B899F056FA095537C5817163801F544BAFCEA39C995D76DB4D16F9DF"; +constexpr auto kMptIssuanceId = "000000014B4E9C06F24296074F7BC48F92A97916C6DC5EA9"; +constexpr auto kMptIssuanceId2 = "000000024B4E9C06F24296074F7BC48F92A97916C6DC5EA9"; constexpr auto kIndex = "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322"; } // namespace @@ -391,6 +394,24 @@ struct AccountTxParameterTest : public RPCAccountTxHandlerTest, })JSON", .expectedError = "invalidParams", .expectedErrorMessage = "Invalid field 'tx_type'." + }, + AccountTxParamTestCaseBundle{ + .testName = "MPTIssuanceIdMalformed", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "mpt_issuance_id": "xxx" + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "mpt_issuance_idMalformed" + }, + AccountTxParamTestCaseBundle{ + .testName = "MPTIssuanceIdNotString", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "mpt_issuance_id": 12 + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "mpt_issuance_idNotString" } }; }; @@ -1746,6 +1767,288 @@ TEST_F(RPCAccountTxHandlerTest, MPTTxs_API_v2) }); } +TEST_F(RPCAccountTxHandlerTest, MPTIssuanceIdFilterMatch) +{ + auto const out = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": 10, + "ledger_index_max": 30, + "transactions": [ + {{ + "meta": {{ + "AffectedNodes": [ + {{ + "CreatedNode": {{ + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "0000000000000000000000000000000000000000000000000000000000000000", + "NewFields": {{ + "Flags": 0, + "Issuer": "{}", + "LedgerEntryType": "MPTokenIssuance", + "MPTokenMetadata": "746573742D6D657461", + "MaximumAmount": "0", + "OutstandingAmount": "0", + "OwnerNode": "0", + "PreviousTxnID": "0000000000000000000000000000000000000000000000000000000000000000", + "PreviousTxnLgrSeq": 0, + "Sequence": 1 + }} + }} + }} + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "mpt_issuance_id": "{}" + }}, + "hash": "A52221F4003C281D3C83F501F418B55A1F9DC1C6A129EF13E1A8F0E5C008DAE3", + "ledger_index": 11, + "ledger_hash": "{}", + "close_time_iso": "2000-01-01T00:00:00Z", + "tx_json": {{ + "Account": "{}", + "Fee": "50", + "Sequence": 1, + "SigningPubKey": "74657374", + "TransactionType": "MPTokenIssuanceCreate", + "ledger_index": 11, + "ctid": "C000000B00000000", + "date": 1 + }}, + "validated": true + }} + ], + "validated": true + }})JSON", + kAccount, + kAccount, + kMptIssuanceId, + kLedgerHash, + kAccount + ); + + auto mptTx = createMPTIssuanceCreateTxWithMetadata(kAccount, 50, 1); + mptTx.ledgerSequence = kMinSeq + 1; + mptTx.date = 1; + + auto transactions = std::vector{std::move(mptTx)}; + auto const transCursor = + TransactionsAndCursor{.txns = std::move(transactions), .cursor = std::nullopt}; + + EXPECT_CALL(*backend_, fetchAccountTransactions).WillOnce(Return(transCursor)); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMinSeq + 1); + EXPECT_CALL(*backend_, fetchLedgerBySequence(kMinSeq + 1, _)).WillOnce(Return(ledgerHeader)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "mpt_issuance_id": "{}" + }})JSON", + kAccount, + kMinSeq, + kMaxSeq, + kMptIssuanceId + ) + ); + auto const output = handler.process(input, Context{.yield = yield, .apiVersion = 2u}); + ASSERT_TRUE(output); + EXPECT_EQ(*output.result, boost::json::parse(out)); + }); +} + +TEST_F(RPCAccountTxHandlerTest, MPTIssuanceIdFilterNoMatch) +{ + auto const out = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": 10, + "ledger_index_max": 30, + "transactions": [], + "validated": true + }})JSON", + kAccount + ); + + auto mptTx = createMPTIssuanceCreateTxWithMetadata(kAccount, 50, 1); + mptTx.ledgerSequence = kMinSeq + 1; + mptTx.date = 1; + + auto transactions = std::vector{std::move(mptTx)}; + auto const transCursor = + TransactionsAndCursor{.txns = std::move(transactions), .cursor = std::nullopt}; + + EXPECT_CALL(*backend_, fetchAccountTransactions).WillOnce(Return(transCursor)); + // the tx is filtered out before the per-tx ledger_hash/close_time_iso enrichment step, so this + // must never be called + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "mpt_issuance_id": "{}" + }})JSON", + kAccount, + kMinSeq, + kMaxSeq, + kMptIssuanceId2 + ) + ); + auto const output = handler.process(input, Context{.yield = yield, .apiVersion = 2u}); + ASSERT_TRUE(output); + EXPECT_EQ(*output.result, boost::json::parse(out)); + }); +} + +TEST_F(RPCAccountTxHandlerTest, MPTIssuanceIdFilterWithMatchingTxType) +{ + auto mptTx = createMPTIssuanceCreateTxWithMetadata(kAccount, 50, 1); + mptTx.ledgerSequence = kMinSeq + 1; + mptTx.date = 1; + + auto transactions = std::vector{std::move(mptTx)}; + auto const transCursor = + TransactionsAndCursor{.txns = std::move(transactions), .cursor = std::nullopt}; + + EXPECT_CALL(*backend_, fetchAccountTransactions).WillOnce(Return(transCursor)); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMinSeq + 1); + EXPECT_CALL(*backend_, fetchLedgerBySequence(kMinSeq + 1, _)).WillOnce(Return(ledgerHeader)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "mpt_issuance_id": "{}", + "tx_type": "MPTokenIssuanceCreate" + }})JSON", + kAccount, + kMinSeq, + kMaxSeq, + kMptIssuanceId + ) + ); + auto const output = handler.process(input, Context{.yield = yield, .apiVersion = 2u}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->as_object().at("transactions").as_array().size(), 1); + }); +} + +TEST_F(RPCAccountTxHandlerTest, MPTIssuanceIdFilterWithMismatchingTxType) +{ + auto mptTx = createMPTIssuanceCreateTxWithMetadata(kAccount, 50, 1); + mptTx.ledgerSequence = kMinSeq + 1; + mptTx.date = 1; + + auto transactions = std::vector{std::move(mptTx)}; + auto const transCursor = + TransactionsAndCursor{.txns = std::move(transactions), .cursor = std::nullopt}; + + EXPECT_CALL(*backend_, fetchAccountTransactions).WillOnce(Return(transCursor)); + // tx_type mismatch causes the transaction to be skipped before the ledger_hash enrichment step + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "mpt_issuance_id": "{}", + "tx_type": "Payment" + }})JSON", + kAccount, + kMinSeq, + kMaxSeq, + kMptIssuanceId + ) + ); + auto const output = handler.process(input, Context{.yield = yield, .apiVersion = 2u}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->as_object().at("transactions").as_array().size(), 0); + }); +} + +TEST_F(RPCAccountTxHandlerTest, MPTIssuanceIdFilterBinary) +{ + auto mptTx = createMPTIssuanceCreateTxWithMetadata(kAccount, 50, 1); + mptTx.ledgerSequence = kMinSeq + 1; + mptTx.date = 1; + + auto const expectedMetaBlob = xrpl::strHex(mptTx.metadata); + auto const expectedTxBlob = xrpl::strHex(mptTx.transaction); + + // non-matching transactions (no mpt_issuance_id reference) mixed in alongside the matching one, + // to prove the binary path actually filters rather than just passing everything through + auto transactions = genTransactions(kMinSeq + 2, kMinSeq + 3); + transactions.push_back(std::move(mptTx)); + auto const transCursor = + TransactionsAndCursor{.txns = std::move(transactions), .cursor = std::nullopt}; + + EXPECT_CALL(*backend_, fetchAccountTransactions).WillOnce(Return(transCursor)); + // the binary path never deserializes the tx to JSON, so no ledger_hash enrichment happens + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + + auto const out = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": 10, + "ledger_index_max": 30, + "transactions": [ + {{ + "meta_blob": "{}", + "tx_blob": "{}", + "ledger_index": {}, + "validated": true + }} + ], + "validated": true + }})JSON", + kAccount, + expectedMetaBlob, + expectedTxBlob, + kMinSeq + 1 + ); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "mpt_issuance_id": "{}", + "binary": true + }})JSON", + kAccount, + kMinSeq, + kMaxSeq, + kMptIssuanceId + ) + ); + auto const output = handler.process(input, Context{.yield = yield, .apiVersion = 2u}); + ASSERT_TRUE(output); + EXPECT_EQ(*output.result, boost::json::parse(out)); + }); +} + struct AccountTxTransactionBundle { std::string testName; std::string testJson;