From eaf4045a580b4f77cc0e0d7a4a50a72fa6ab3420 Mon Sep 17 00:00:00 2001 From: Bryan Jiang Date: Fri, 24 Jul 2026 12:26:44 -0400 Subject: [PATCH 1/2] feat: Add accounts filter to mpt_holders --- src/rpc/handlers/MPTHolders.cpp | 152 ++++-- src/rpc/handlers/MPTHolders.hpp | 42 ++ tests/unit/rpc/handlers/MPTHoldersTests.cpp | 542 ++++++++++++++++++++ 3 files changed, 682 insertions(+), 54 deletions(-) diff --git a/src/rpc/handlers/MPTHolders.cpp b/src/rpc/handlers/MPTHolders.cpp index ba67302ca5..a83db1e3f4 100644 --- a/src/rpc/handlers/MPTHolders.cpp +++ b/src/rpc/handlers/MPTHolders.cpp @@ -24,14 +24,84 @@ #include #include #include +#include using namespace xrpl; namespace rpc { +namespace { + +/** + * @brief Serialize a single MPToken ledger object blob into the mpt_holders JSON shape. + * + * @param mptID The MPTokenIssuance ID the holder belongs to. + * @param mpt The serialized MPToken ledger object. + * @return The holder entry as a JSON object. + */ +boost::json::object +mpTokenToJson(xrpl::uint192 const& mptID, data::Blob const& mpt) +{ + xrpl::STLedgerEntry const sle{ + xrpl::SerialIter{mpt.data(), mpt.size()}, keylet::mptokenIssuance(mptID).key + }; + boost::json::object mptJson; + + mptJson[JS(account)] = toBase58(sle[xrpl::sfAccount]); + mptJson[JS(flags)] = sle.getFlags(); + mptJson[JS(mpt_amount)] = toBoostJson( + xrpl::STUInt64{xrpl::sfMPTAmount, sle[xrpl::sfMPTAmount]}.getJson(JsonOptions::Values::None) + ); + mptJson[JS(mptoken_index)] = + xrpl::to_string(xrpl::keylet::mptoken(mptID, sle[xrpl::sfAccount]).key); + + if (sle.isFieldPresent(xrpl::sfLockedAmount)) { + mptJson["locked_amount"] = toBoostJson( + xrpl::STUInt64{xrpl::sfLockedAmount, sle[xrpl::sfLockedAmount]}.getJson( + JsonOptions::Values::None + ) + ); + } + + if (sle.isFieldPresent(xrpl::sfConfidentialBalanceInbox)) { + mptJson[JS(confidential_balance_inbox)] = + xrpl::strHex(sle.getFieldVL(xrpl::sfConfidentialBalanceInbox)); + } + + if (sle.isFieldPresent(xrpl::sfConfidentialBalanceSpending)) { + mptJson[JS(confidential_balance_spending)] = + xrpl::strHex(sle.getFieldVL(xrpl::sfConfidentialBalanceSpending)); + } + + if (sle.isFieldPresent(xrpl::sfConfidentialBalanceVersion)) + mptJson[JS(confidential_balance_version)] = sle[xrpl::sfConfidentialBalanceVersion]; + + if (sle.isFieldPresent(xrpl::sfIssuerEncryptedBalance)) { + mptJson[JS(issuer_encrypted_balance)] = + xrpl::strHex(sle.getFieldVL(xrpl::sfIssuerEncryptedBalance)); + } + + if (sle.isFieldPresent(xrpl::sfAuditorEncryptedBalance)) { + mptJson[JS(auditor_encrypted_balance)] = + xrpl::strHex(sle.getFieldVL(xrpl::sfAuditorEncryptedBalance)); + } + + if (sle.isFieldPresent(xrpl::sfHolderEncryptionKey)) { + mptJson[JS(holder_encryption_key)] = + xrpl::strHex(sle.getFieldVL(xrpl::sfHolderEncryptionKey)); + } + + return mptJson; +} + +} // namespace + MPTHoldersHandler::Result MPTHoldersHandler::process(MPTHoldersHandler::Input const& input, Context const& ctx) const { + if (input.accounts && input.marker) + return Error{Status{RippledError::RpcInvalidParams, "accountsWithMarker"}}; + auto const range = sharedPtrBackend_->fetchLedgerRange(); ASSERT(range.has_value(), "MPTHolder's ledger range must be available"); @@ -55,72 +125,40 @@ MPTHoldersHandler::process(MPTHoldersHandler::Input const& input, Context const& if (!issuanceLedgerObject) return Error{Status{RippledError::RpcObjectNotFound, "objectNotFound"}}; - std::optional cursor; - if (input.marker) - cursor = xrpl::AccountID{input.marker->c_str()}; - - auto const dbResponse = - sharedPtrBackend_->fetchMPTHolders(mptID, limit, cursor, lgrInfo.seq, ctx.yield); auto output = MPTHoldersHandler::Output{}; output.mptID = to_string(mptID); output.limit = limit; output.ledgerIndex = lgrInfo.seq; - boost::json::array const mpts; - for (auto const& mpt : dbResponse.mptokens) { - xrpl::STLedgerEntry const sle{ - xrpl::SerialIter{mpt.data(), mpt.size()}, keylet::mptokenIssuance(mptID).key - }; - boost::json::object mptJson; - - mptJson[JS(account)] = toBase58(sle[xrpl::sfAccount]); - mptJson[JS(flags)] = sle.getFlags(); - mptJson[JS(mpt_amount)] = toBoostJson( - xrpl::STUInt64{xrpl::sfMPTAmount, sle[xrpl::sfMPTAmount]}.getJson( - JsonOptions::Values::None - ) - ); - mptJson[JS(mptoken_index)] = - xrpl::to_string(xrpl::keylet::mptoken(mptID, sle[xrpl::sfAccount]).key); - - if (sle.isFieldPresent(xrpl::sfLockedAmount)) { - mptJson["locked_amount"] = toBoostJson( - xrpl::STUInt64{xrpl::sfLockedAmount, sle[xrpl::sfLockedAmount]}.getJson( - JsonOptions::Values::None - ) - ); + // Account-list filter: bounded lookup by key, so non-holders are dropped and no + // paging marker is produced. + if (input.accounts) { + std::vector keys; + keys.reserve(input.accounts->size()); + for (auto const& account : *input.accounts) { + auto const accountID = accountFromStringStrict(account); + ASSERT(accountID.has_value(), "Account must be valid after spec validation"); + keys.push_back(xrpl::keylet::mptoken(mptID, *accountID).key); } - if (sle.isFieldPresent(xrpl::sfConfidentialBalanceInbox)) { - mptJson[JS(confidential_balance_inbox)] = - xrpl::strHex(sle.getFieldVL(xrpl::sfConfidentialBalanceInbox)); + auto const mptObjects = sharedPtrBackend_->fetchLedgerObjects(keys, lgrInfo.seq, ctx.yield); + for (auto const& mpt : mptObjects) { + if (not mpt.empty()) + output.mpts.push_back(mpTokenToJson(mptID, mpt)); } - if (sle.isFieldPresent(xrpl::sfConfidentialBalanceSpending)) { - mptJson[JS(confidential_balance_spending)] = - xrpl::strHex(sle.getFieldVL(xrpl::sfConfidentialBalanceSpending)); - } - - if (sle.isFieldPresent(xrpl::sfConfidentialBalanceVersion)) - mptJson[JS(confidential_balance_version)] = sle[xrpl::sfConfidentialBalanceVersion]; - - if (sle.isFieldPresent(xrpl::sfIssuerEncryptedBalance)) { - mptJson[JS(issuer_encrypted_balance)] = - xrpl::strHex(sle.getFieldVL(xrpl::sfIssuerEncryptedBalance)); - } + return output; + } - if (sle.isFieldPresent(xrpl::sfAuditorEncryptedBalance)) { - mptJson[JS(auditor_encrypted_balance)] = - xrpl::strHex(sle.getFieldVL(xrpl::sfAuditorEncryptedBalance)); - } + std::optional cursor; + if (input.marker) + cursor = xrpl::AccountID{input.marker->c_str()}; - if (sle.isFieldPresent(xrpl::sfHolderEncryptionKey)) { - mptJson[JS(holder_encryption_key)] = - xrpl::strHex(sle.getFieldVL(xrpl::sfHolderEncryptionKey)); - } + auto const dbResponse = + sharedPtrBackend_->fetchMPTHolders(mptID, limit, cursor, lgrInfo.seq, ctx.yield); - output.mpts.push_back(mptJson); - } + for (auto const& mpt : dbResponse.mptokens) + output.mpts.push_back(mpTokenToJson(mptID, mpt)); if (dbResponse.cursor.has_value()) output.marker = strHex(*dbResponse.cursor); @@ -170,6 +208,12 @@ tag_invoke(boost::json::value_to_tag, boost::json::val if (jsonObject.contains(JS(marker))) input.marker = jsonObject.at(JS(marker)).as_string().c_str(); + if (jsonObject.contains(JS(accounts))) { + auto& accounts = input.accounts.emplace(); + for (auto const& account : jsonObject.at(JS(accounts)).as_array()) + accounts.emplace_back(account.as_string().c_str()); + } + return input; } } // namespace rpc diff --git a/src/rpc/handlers/MPTHolders.hpp b/src/rpc/handlers/MPTHolders.hpp index daed106ed2..90eccfb165 100644 --- a/src/rpc/handlers/MPTHolders.hpp +++ b/src/rpc/handlers/MPTHolders.hpp @@ -1,21 +1,28 @@ #pragma once #include "data/BackendInterface.hpp" +#include "rpc/Errors.hpp" #include "rpc/JS.hpp" #include "rpc/common/Modifiers.hpp" #include "rpc/common/Specs.hpp" #include "rpc/common/Types.hpp" #include "rpc/common/Validators.hpp" +#include "util/AccountUtils.hpp" #include #include #include +#include +#include #include +#include #include #include #include #include +#include +#include namespace rpc { @@ -30,6 +37,7 @@ class MPTHoldersHandler { static constexpr auto kLimitMin = 1; static constexpr auto kLimitMax = 100; static constexpr auto kLimitDefault = 50; + static constexpr auto kMaxAccounts = 100; /** * @brief A struct to hold the output data of the command @@ -52,6 +60,7 @@ class MPTHoldersHandler { std::optional ledgerIndex; std::optional marker; std::optional limit; + std::optional> accounts; }; using Result = HandlerReturnType; @@ -75,6 +84,38 @@ class MPTHoldersHandler { static RpcSpecConstRef spec([[maybe_unused]] uint32_t apiVersion) { + // Optional filter: when present, return only these accounts' MPToken state for the + // issuance. + static auto const kAccountsValidator = validation::CustomValidator{ + [](boost::json::value const& value, std::string_view key) -> MaybeError { + if (!value.is_array()) { + return Error{ + Status{RippledError::RpcInvalidParams, std::string{key} + "NotArray"} + }; + } + + auto const& accounts = value.as_array(); + if (accounts.empty() || accounts.size() > static_cast(kMaxAccounts)) { + return Error{ + Status{RippledError::RpcInvalidParams, std::string{key} + "Malformed"} + }; + } + + for (auto const& account : accounts) { + if (!account.is_string() || + !util::parseBase58Wrapper( + boost::json::value_to(account) + )) { + return Error{ + Status{RippledError::RpcInvalidParams, std::string{key} + "Malformed"} + }; + } + } + + return MaybeError{}; + } + }; + static auto const kRpcSpec = RpcSpec{ {JS(mpt_issuance_id), validation::Required{}, @@ -86,6 +127,7 @@ class MPTHoldersHandler { validation::Min(1u), modifiers::Clamp{kLimitMin, kLimitMax}}, {JS(marker), validation::CustomValidators::uint160HexStringValidator}, + {JS(accounts), kAccountsValidator}, }; return kRpcSpec; diff --git a/tests/unit/rpc/handlers/MPTHoldersTests.cpp b/tests/unit/rpc/handlers/MPTHoldersTests.cpp index 9d4321a593..6d3e399388 100644 --- a/tests/unit/rpc/handlers/MPTHoldersTests.cpp +++ b/tests/unit/rpc/handlers/MPTHoldersTests.cpp @@ -730,6 +730,548 @@ TEST_F(RPCMPTHoldersHandlerTest, MultipleMPTs) }); } +TEST_F(RPCMPTHoldersHandlerTest, AccountsNotArray) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": "{}" + }})JSON", + kMptId, + kHoldeR1Account + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsNotArray"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsEmpty) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": [] + }})JSON", + kMptId + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsMalformed"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsMalformedEntry) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "not-an-account"] + }})JSON", + kMptId, + kHoldeR1Account + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsMalformed"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsTooMany) +{ + std::string accountsList; + for (auto i = 0; i <= MPTHoldersHandler::kMaxAccounts; ++i) + accountsList += fmt::format(R"("{}",)", kHoldeR1Account); + accountsList.pop_back(); // drop trailing comma + + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": [{}] + }})JSON", + kMptId, + accountsList + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsMalformed"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsWithMarker) +{ + auto const marker = xrpl::strHex(getAccountIdWithString(kHoldeR1Account)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}"], + "marker": "{}" + }})JSON", + kMptId, + kHoldeR1Account, + marker + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsWithMarker"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilter) +{ + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": 30, + "mptokens": [{}, {}], + "validated": true + }})JSON", + kMptId, + kMptOuT1, + kMptOuT2 + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken1 = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + auto const mptoken2 = createMpTokenObject(kHoldeR2Account, xrpl::uint192(kMptId)); + std::vector const objs = { + mptoken1.getSerializer().peekData(), mptoken2.getSerializer().peekData() + }; + // The handler must query exactly the MPToken keys for the requested accounts, in order. + std::vector const expectedKeys = { + xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHoldeR1Account)).key, + xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHoldeR2Account)).key + }; + EXPECT_CALL(*backend_, doFetchLedgerObjects(expectedKeys, 30, _)).WillOnce(Return(objs)); + // The accounts filter must not fall back to the holder-index scan. + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "{}"] + }})JSON", + kMptId, + kHoldeR1Account, + kHoldeR2Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterMissingHolderOmitted) +{ + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": 30, + "mptokens": [{}], + "validated": true + }})JSON", + kMptId, + kMptOuT1 + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken1 = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + // holder2 is not a holder: its object comes back empty and must be dropped. + std::vector const objs = {mptoken1.getSerializer().peekData(), Blob{}}; + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(objs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(1); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "{}"] + }})JSON", + kMptId, + kHoldeR1Account, + kHoldeR2Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsNonStringEntry) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend_}}; + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", 123] + }})JSON", + kMptId, + kHoldeR1Account + ) + ); + auto const output = handler.process(input, Context{.yield = std::ref(yield)}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "accountsMalformed"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsMaxAllowed) +{ + std::string accountsList; + for (auto i = 0; i < MPTHoldersHandler::kMaxAccounts; ++i) + accountsList += fmt::format(R"("{}",)", kHoldeR1Account); + accountsList.pop_back(); // drop trailing comma + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + std::vector const objs( + MPTHoldersHandler::kMaxAccounts, mptoken.getSerializer().peekData() + ); + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(objs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(1); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": [{}] + }})JSON", + kMptId, + accountsList + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ( + static_cast(output.result->as_object().at("mptokens").as_array().size()), + MPTHoldersHandler::kMaxAccounts + ); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterAllAbsent) +{ + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": 30, + "mptokens": [], + "validated": true + }})JSON", + kMptId + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + std::vector const objs = {Blob{}, Blob{}}; + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(objs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(1); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "{}"] + }})JSON", + kMptId, + kHoldeR1Account, + kHoldeR2Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterConfidentialFields) +{ + constexpr auto kConfidentialBalanceInbox = "inbox-ciphertext"; + constexpr auto kConfidentialBalanceSpending = "spending-ciphertext"; + constexpr auto kConfidentialBalanceVersion = 3u; + constexpr auto kIssuerEncryptedBalance = "issuer-balance-ciphertext"; + constexpr auto kAuditorEncryptedBalance = "auditor-balance-ciphertext"; + constexpr auto kHolderEncryptionKey = "holder-pubkey"; + constexpr auto kLockedAmount = 5; + + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": 30, + "mptokens": [{{ + "account": "{}", + "flags": 0, + "mpt_amount": "1", + "mptoken_index": "D137F2E5A5767A06CB7A8F060ADE442A30CFF95028E1AF4B8767E3A56877205A", + "locked_amount": "{}", + "confidential_balance_inbox": "{}", + "confidential_balance_spending": "{}", + "confidential_balance_version": {}, + "issuer_encrypted_balance": "{}", + "auditor_encrypted_balance": "{}", + "holder_encryption_key": "{}" + }}], + "validated": true + }})JSON", + kMptId, + kHoldeR1Account, + kLockedAmount, + xrpl::strHex(std::string_view{kConfidentialBalanceInbox}), + xrpl::strHex(std::string_view{kConfidentialBalanceSpending}), + kConfidentialBalanceVersion, + xrpl::strHex(std::string_view{kIssuerEncryptedBalance}), + xrpl::strHex(std::string_view{kAuditorEncryptedBalance}), + xrpl::strHex(std::string_view{kHolderEncryptionKey}) + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = createMpTokenObject( + kHoldeR1Account, + xrpl::uint192(kMptId), + 1, + 0, + kLockedAmount, + kConfidentialBalanceInbox, + kConfidentialBalanceSpending, + kConfidentialBalanceVersion, + kIssuerEncryptedBalance, + kAuditorEncryptedBalance, + kHolderEncryptionKey + ); + std::vector const objs = {mptoken.getSerializer().peekData()}; + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(objs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(1); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}"] + }})JSON", + kMptId, + kHoldeR1Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterMPTNotFound) +{ + auto ledgerinfo = createLedgerHeader(kLedgerHash, 30); + ON_CALL(*backend_, fetchLedgerByHash(xrpl::uint256{kLedgerHash}, _)) + .WillByDefault(Return(ledgerinfo)); + EXPECT_CALL(*backend_, fetchLedgerByHash).Times(1); + ON_CALL(*backend_, doFetchLedgerObject).WillByDefault(Return(std::optional{})); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(1); + // Must not attempt to fetch holder objects when the issuance does not exist. + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}", + "accounts": ["{}"] + }})JSON", + kMptId, + kLedgerHash, + kHoldeR1Account + ) + ); + runSpawn([&, this](boost::asio::yield_context yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{.yield = yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "objectNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "objectNotFound"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterSpecificLedger) +{ + auto const specificLedger = 20; + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": {}, + "mptokens": [{}], + "validated": true + }})JSON", + kMptId, + specificLedger, + kMptOuT1 + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, specificLedger); + ON_CALL(*backend_, fetchLedgerBySequence(specificLedger, _)).WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, specificLedger, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken1 = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + std::vector const objs = {mptoken1.getSerializer().peekData()}; + std::vector const expectedKeys = { + xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHoldeR1Account)).key + }; + EXPECT_CALL(*backend_, doFetchLedgerObjects(expectedKeys, specificLedger, _)) + .WillOnce(Return(objs)); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index": {}, + "accounts": ["{}"] + }})JSON", + kMptId, + specificLedger, + kHoldeR1Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsFilterDuplicates) +{ + auto const currentOutput = fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": 50, + "ledger_index": 30, + "mptokens": [{}, {}], + "validated": true + }})JSON", + kMptId, + kMptOuT1, + kMptOuT1 + ); + + auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = xrpl::keylet::mptokenIssuance(xrpl::uint192(kMptId)).key; + ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken1 = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + std::vector const objs = { + mptoken1.getSerializer().peekData(), mptoken1.getSerializer().peekData() + }; + auto const key = + xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHoldeR1Account)).key; + std::vector const expectedKeys = {key, key}; + EXPECT_CALL(*backend_, doFetchLedgerObjects(expectedKeys, 30, _)).WillOnce(Return(objs)); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "{}"] + }})JSON", + kMptId, + kHoldeR1Account, + kHoldeR1Account + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(boost::json::parse(currentOutput), *output.result); + }); +} + TEST_F(RPCMPTHoldersHandlerTest, LimitMoreThanMAx) { auto const currentOutput = fmt::format( From d6653d19f9daa2b9e6549747384115817ecd699a Mon Sep 17 00:00:00 2001 From: Bryan Jiang Date: Fri, 24 Jul 2026 13:34:09 -0400 Subject: [PATCH 2/2] fix clang tidy --- src/rpc/handlers/MPTHolders.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rpc/handlers/MPTHolders.cpp b/src/rpc/handlers/MPTHolders.cpp index a83db1e3f4..862114c6ab 100644 --- a/src/rpc/handlers/MPTHolders.cpp +++ b/src/rpc/handlers/MPTHolders.cpp @@ -1,5 +1,6 @@ #include "rpc/handlers/MPTHolders.hpp" +#include "data/Types.hpp" #include "rpc/Errors.hpp" #include "rpc/JS.hpp" #include "rpc/RPCHelpers.hpp" @@ -138,6 +139,7 @@ MPTHoldersHandler::process(MPTHoldersHandler::Input const& input, Context const& for (auto const& account : *input.accounts) { auto const accountID = accountFromStringStrict(account); ASSERT(accountID.has_value(), "Account must be valid after spec validation"); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) keys.push_back(xrpl::keylet::mptoken(mptID, *accountID).key); }