From 1621ac353c668237da588be92ebe6e1b9c050cb9 Mon Sep 17 00:00:00 2001 From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:07:55 -0700 Subject: [PATCH] feat: Add accounts filter to mpt_holders (#3180) --- conan.lock | 2 +- conanfile.py | 2 +- src/rpc/handlers/MPTHolders.cpp | 152 +++-- src/rpc/handlers/MPTHolders.hpp | 8 +- tests/unit/rpc/handlers/BookOffersTests.cpp | 6 +- tests/unit/rpc/handlers/MPTHoldersTests.cpp | 622 +++++++++++++++++++- tests/unit/rpc/handlers/TxTests.cpp | 4 +- tests/unit/rpc/handlers/VaultInfoTests.cpp | 10 +- 8 files changed, 717 insertions(+), 89 deletions(-) diff --git a/conan.lock b/conan.lock index 9cb2ca51b..0025164a0 100644 --- a/conan.lock +++ b/conan.lock @@ -3,7 +3,7 @@ "requires": [ "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708", "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688", - "xrpl-rpc-spec/0.1.13#9777f2a775dc8152b7e3853475c9e177%1789657012.888807", + "xrpl-rpc-spec/0.1.14#de860864925a4536a97bb7c4d01caddc%1789662365.154657", "xrpl/3.4.0-rc1#19678cbb46117ef8a669558ad19d6a1f%1789050823.813662", "sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447", "spdlog/1.17.0#bcbaaf7147bda6ad24ffbd1ac3d7142c%1782736610.443882", diff --git a/conanfile.py b/conanfile.py index 037503b10..7299eb5af 100644 --- a/conanfile.py +++ b/conanfile.py @@ -17,7 +17,7 @@ class ClioConan(ConanFile): "fmt/12.1.0", "libbacktrace/cci.20210118", "spdlog/1.17.0", - "xrpl-rpc-spec/0.1.13", + "xrpl-rpc-spec/0.1.14", "xrpl/3.4.0-rc1", ] diff --git a/src/rpc/handlers/MPTHolders.cpp b/src/rpc/handlers/MPTHolders.cpp index 582c35406..370dce604 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/JS.hpp" #include "rpc/RPCHelpers.hpp" #include "rpc/common/Types.hpp" @@ -21,16 +22,89 @@ #include #include -#include -#include +#include +#include using namespace xrpl; namespace rpc { +namespace { + +/** + * @brief Serialize a single MPToken ledger object blob into the mpt_holders JSON. + * + * @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()}, xrpl::uint256{}}; + auto const mptokenKey = keylet::mptoken(mptID, sle[xrpl::sfAccount]).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(mptokenKey); + + 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 { + // Account-list filter is a bounded, unpaginated lookup by key: marker/limit does not make sense + // alongside it. + if (input.accounts) { + if (input.marker.has_value()) + return Error{Status{RippledError::RpcInvalidParams, "accountsWithMarker"}}; + if (input.limit.has_value()) + return Error{Status{RippledError::RpcInvalidParams, "accountsWithLimit"}}; + } + auto const range = sharedPtrBackend_->fetchLedgerRange(); ASSERT(range.has_value(), "MPTHolder's ledger range must be available"); @@ -44,7 +118,7 @@ MPTHoldersHandler::process(MPTHoldersHandler::Input const& input, Context const& return Error{expectedLgrInfo.error()}; auto const& lgrInfo = *expectedLgrInfo; - auto const limit = input.limit; + auto const limit = input.limit.value_or(MPTHoldersHandler::kLimitDefault); auto const& mptID = input.mptID; auto const issuanceLedgerObject = sharedPtrBackend_->fetchLedgerObject( @@ -53,69 +127,37 @@ MPTHoldersHandler::process(MPTHoldersHandler::Input const& input, Context const& if (!issuanceLedgerObject) return Error{Status{RippledError::RpcObjectNotFound, "objectNotFound"}}; - auto const dbResponse = - sharedPtrBackend_->fetchMPTHolders(mptID, limit, input.marker, 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. Duplicates are dropped in first-seen + // order, non-holders are omitted, and no paging marker is produced. + if (input.accounts) { + std::vector keys; + keys.reserve(input.accounts->size()); + for (auto const& accountID : *input.accounts) { + auto const key = xrpl::keylet::mptoken(mptID, accountID).key; + if (not std::ranges::contains(keys, key)) + keys.push_back(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)); - } - - 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)); - } - - output.mpts.push_back(mptJson); + return output; } + auto const dbResponse = + sharedPtrBackend_->fetchMPTHolders(mptID, limit, input.marker, lgrInfo.seq, ctx.yield); + + for (auto const& mpt : dbResponse.mptokens) + output.mpts.push_back(mpTokenToJson(mptID, mpt)); + if (dbResponse.cursor.has_value()) output.marker = strHex(*dbResponse.cursor); diff --git a/src/rpc/handlers/MPTHolders.hpp b/src/rpc/handlers/MPTHolders.hpp index cd77944c9..0b65e38a5 100644 --- a/src/rpc/handlers/MPTHolders.hpp +++ b/src/rpc/handlers/MPTHolders.hpp @@ -18,8 +18,13 @@ namespace rpc { /** - * @brief The mpt_holders command asks the Clio server for all holders of a particular + * @brief The mpt_holders command asks the Clio server for holders of a particular * MPTokenIssuance. + * + * When `accounts` is provided, those accounts are looked up directly by + * `keylet::mptoken` instead of scanning the holder index. Duplicate accounts are + * collapsed in first-seen order and non-holders are omitted. This filtered mode is + * not paginated, so `marker` and `limit` are rejected. */ class MPTHoldersHandler : public rpc::spec::HandlerFor { std::shared_ptr sharedPtrBackend_; @@ -28,6 +33,7 @@ public: static constexpr auto kLimitMin = rpc::spec::handlers::mpt_holders::kLimitMin; static constexpr auto kLimitMax = rpc::spec::handlers::mpt_holders::kLimitMax; static constexpr auto kLimitDefault = rpc::spec::handlers::mpt_holders::kLimitDefault; + static constexpr auto kMaxAccounts = rpc::spec::handlers::mpt_holders::kMaxAccounts; /** * @brief A struct to hold the output data of the command diff --git a/tests/unit/rpc/handlers/BookOffersTests.cpp b/tests/unit/rpc/handlers/BookOffersTests.cpp index 846e28852..591e9fe90 100644 --- a/tests/unit/rpc/handlers/BookOffersTests.cpp +++ b/tests/unit/rpc/handlers/BookOffersTests.cpp @@ -194,8 +194,10 @@ generateParameterBookOffersTestBundles() .expectedErrorMessage = "Source currency is malformed." }, ParameterTestBundle{ - // A present-but-non-string currency is reported by validateTakerJSON as an - // expectedFieldError ('.currency', not string) before the per-field validators. + // Clio deliberately skips the early "is currency a string" check (see kTakerValidator) + // and lets it fall through to the section's own currency validator, which is wrapped + // in withCustomError(currency, RpcDstAmtMalformed) with no message override, so the + // default rippled message/token for that code is what surfaces. .testName = "TakerGetsCurrencyNotString", .testJson = R"JSON({ "taker_gets": { diff --git a/tests/unit/rpc/handlers/MPTHoldersTests.cpp b/tests/unit/rpc/handlers/MPTHoldersTests.cpp index 9d4321a59..ce0694846 100644 --- a/tests/unit/rpc/handlers/MPTHoldersTests.cpp +++ b/tests/unit/rpc/handlers/MPTHoldersTests.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -28,12 +29,12 @@ using namespace testing; namespace { -constexpr auto kHoldeR1Account = "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN"; -constexpr auto kHoldeR2Account = "rEiNkzogdHEzUxPfsri5XSMqtXUixf2Yx"; +constexpr auto kHolder1Account = "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN"; +constexpr auto kHolder2Account = "rEiNkzogdHEzUxPfsri5XSMqtXUixf2Yx"; constexpr auto kLedgerHash = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; constexpr auto kMptId = "000004C463C52827307480341125DA0577DEFC38405B0E3E"; -std::string const kMptOuT1 = +std::string const kMptOut1 = R"JSON({ "account": "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN", "flags": 0, @@ -41,7 +42,7 @@ std::string const kMptOuT1 = "mptoken_index": "D137F2E5A5767A06CB7A8F060ADE442A30CFF95028E1AF4B8767E3A56877205A" })JSON"; -std::string const kMptOuT2 = +std::string const kMptOut2 = R"JSON({ "account": "rEiNkzogdHEzUxPfsri5XSMqtXUixf2Yx", "flags": 0, @@ -389,7 +390,7 @@ TEST_F(RPCMPTHoldersHandlerTest, DefaultParameters) "validated": true }})JSON", kMptId, - kMptOuT1 + kMptOut1 ); auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); @@ -398,7 +399,7 @@ TEST_F(RPCMPTHoldersHandlerTest, DefaultParameters) ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); - auto const mptoken = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + auto const mptoken = createMpTokenObject(kHolder1Account, xrpl::uint192(kMptId)); std::vector const mpts = {mptoken.getSerializer().peekData()}; ON_CALL(*backend_, fetchMPTHolders) .WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = {}})); @@ -452,7 +453,7 @@ TEST_F(RPCMPTHoldersHandlerTest, CustomAmounts) ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); - auto const mptoken = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId), 0); + auto const mptoken = createMpTokenObject(kHolder1Account, xrpl::uint192(kMptId), 0); std::vector const mpts = {mptoken.getSerializer().peekData()}; ON_CALL(*backend_, fetchMPTHolders) .WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = {}})); @@ -511,7 +512,7 @@ TEST_F(RPCMPTHoldersHandlerTest, ConfidentialFields) "validated": true }})JSON", kMptId, - kHoldeR1Account, + kHolder1Account, kLockedAmount, xrpl::strHex(std::string_view{kConfidentialBalanceInbox}), xrpl::strHex(std::string_view{kConfidentialBalanceSpending}), @@ -528,7 +529,7 @@ TEST_F(RPCMPTHoldersHandlerTest, ConfidentialFields) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); auto const mptoken = createMpTokenObject( - kHoldeR1Account, + kHolder1Account, xrpl::uint192(kMptId), 1, 0, @@ -580,7 +581,7 @@ TEST_F(RPCMPTHoldersHandlerTest, SpecificLedgerIndex) }})JSON", kMptId, specificLedger, - kMptOuT1 + kMptOut1 ); auto ledgerInfo = createLedgerHeader(kLedgerHash, specificLedger); @@ -590,7 +591,7 @@ TEST_F(RPCMPTHoldersHandlerTest, SpecificLedgerIndex) ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, specificLedger, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); - auto const mptoken = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + auto const mptoken = createMpTokenObject(kHolder1Account, xrpl::uint192(kMptId)); std::vector const mpts = {mptoken.getSerializer().peekData()}; ON_CALL(*backend_, fetchMPTHolders) .WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = {}})); @@ -636,8 +637,8 @@ TEST_F(RPCMPTHoldersHandlerTest, MarkerParameter) "marker": "{}" }})JSON", kMptId, - kMptOuT2, - xrpl::strHex(getAccountIdWithString(kHoldeR1Account)) + kMptOut2, + xrpl::strHex(getAccountIdWithString(kHolder1Account)) ); auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); @@ -646,9 +647,9 @@ TEST_F(RPCMPTHoldersHandlerTest, MarkerParameter) ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); - auto const mptoken = createMpTokenObject(kHoldeR2Account, xrpl::uint192(kMptId)); + auto const mptoken = createMpTokenObject(kHolder2Account, xrpl::uint192(kMptId)); std::vector const mpts = {mptoken.getSerializer().peekData()}; - auto const marker = getAccountIdWithString(kHoldeR1Account); + auto const marker = getAccountIdWithString(kHolder1Account); ON_CALL(*backend_, fetchMPTHolders) .WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = marker})); EXPECT_CALL( @@ -659,7 +660,7 @@ TEST_F(RPCMPTHoldersHandlerTest, MarkerParameter) ) .Times(1); - auto const holder1AccountId = xrpl::strHex(getAccountIdWithString(kHoldeR1Account)); + auto const holder1AccountId = xrpl::strHex(getAccountIdWithString(kHolder1Account)); auto const input = boost::json::parse( fmt::format( R"JSON({{ @@ -689,8 +690,8 @@ TEST_F(RPCMPTHoldersHandlerTest, MultipleMPTs) "validated": true }})JSON", kMptId, - kMptOuT1, - kMptOuT2 + kMptOut1, + kMptOut2 ); auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); @@ -699,8 +700,8 @@ TEST_F(RPCMPTHoldersHandlerTest, MultipleMPTs) 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)); + auto const mptoken1 = createMpTokenObject(kHolder1Account, xrpl::uint192(kMptId)); + auto const mptoken2 = createMpTokenObject(kHolder2Account, xrpl::uint192(kMptId)); std::vector const mpts = { mptoken1.getSerializer().peekData(), mptoken2.getSerializer().peekData() }; @@ -730,6 +731,583 @@ 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(), "Invalid field 'accounts', not array."); + }); +} + +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(), + "Invalid field 'accounts', not an array of 1 to 100 account IDs." + ); + }); +} + +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(), + "Invalid field 'accounts', not an array of account IDs." + ); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsTooMany) +{ + std::string accountsList; + for (std::size_t 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(), + "Invalid field 'accounts', not an array of 1 to 100 account IDs." + ); + }); +} + +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, AccountsWithLimit) +{ + 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": ["{}"], + "limit": 1 + }})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(), "accountsWithLimit"); + }); +} + +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(), + "Invalid field 'accounts', not an array of account IDs." + ); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, AccountsMaxAllowed) +{ + std::string accountsList; + for (std::size_t 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 = {mptoken.getSerializer().peekData()}; + auto const key = + xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHolder1Account)).key; + std::vector const expectedKeys = {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, + accountsList + ) + ); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->as_object().at("mptokens").as_array().size(), 1u); + }); +} + +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, + 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() + }; + 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)); + EXPECT_CALL(*backend_, fetchMPTHolders).Times(0); + + auto const input = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "accounts": ["{}", "{}", "{}"] + }})JSON", + kMptId, + kHolder1Account, + kHolder2Account, + 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( @@ -741,7 +1319,7 @@ TEST_F(RPCMPTHoldersHandlerTest, LimitMoreThanMAx) "validated": true }})JSON", kMptId, - kMptOuT1 + kMptOut1 ); auto ledgerInfo = createLedgerHeader(kLedgerHash, 30); @@ -750,7 +1328,7 @@ TEST_F(RPCMPTHoldersHandlerTest, LimitMoreThanMAx) ON_CALL(*backend_, doFetchLedgerObject(issuanceKk, 30, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); - auto const mptoken = createMpTokenObject(kHoldeR1Account, xrpl::uint192(kMptId)); + auto const mptoken = createMpTokenObject(kHolder1Account, xrpl::uint192(kMptId)); std::vector const mpts = {mptoken.getSerializer().peekData()}; ON_CALL(*backend_, fetchMPTHolders) .WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = {}})); diff --git a/tests/unit/rpc/handlers/TxTests.cpp b/tests/unit/rpc/handlers/TxTests.cpp index 02cd9036c..06611e3af 100644 --- a/tests/unit/rpc/handlers/TxTests.cpp +++ b/tests/unit/rpc/handlers/TxTests.cpp @@ -151,8 +151,8 @@ TEST_F(RPCTxTest, ExcessiveLgrRange) }); } -// `transaction` is validated by rpc-spec's uint256 converter, which reports one -// format-agnostic message for both a non-string and an unparsable hash. +// `transaction` is validated by rpc-spec's uint256 converter, which reports a distinct +// message for a non-string value versus an unparsable (but string) hash. TEST_F(RPCTxTest, TransactionNotString) { runSpawn([this](auto yield) { diff --git a/tests/unit/rpc/handlers/VaultInfoTests.cpp b/tests/unit/rpc/handlers/VaultInfoTests.cpp index 0d7c8f278..ac9bb482c 100644 --- a/tests/unit/rpc/handlers/VaultInfoTests.cpp +++ b/tests/unit/rpc/handlers/VaultInfoTests.cpp @@ -99,7 +99,7 @@ generateTestValuesForParametersTest() "seq": "asdf" })JSON", .expectedError = "malformedRequest", - .expectedErrorCode = ClioError::RpcMalformedRequest, + .expectedErrorCode = rpc::ClioError::RpcMalformedRequest, .expectedErrorMessage = "Malformed request." }, VaultInfoParamTestCaseBundle{ @@ -109,7 +109,7 @@ generateTestValuesForParametersTest() "seq": 3 })JSON", .expectedError = "malformedRequest", - .expectedErrorCode = ClioError::RpcMalformedRequest, + .expectedErrorCode = rpc::ClioError::RpcMalformedRequest, .expectedErrorMessage = "OwnerNotHexString" }, VaultInfoParamTestCaseBundle{ @@ -119,7 +119,7 @@ generateTestValuesForParametersTest() "seq": 3 })JSON", .expectedError = "malformedRequest", - .expectedErrorCode = ClioError::RpcMalformedRequest, + .expectedErrorCode = rpc::ClioError::RpcMalformedRequest, .expectedErrorMessage = "OwnerNotHexString" }, VaultInfoParamTestCaseBundle{ @@ -128,7 +128,7 @@ generateTestValuesForParametersTest() "vault_id": 3 })JSON", .expectedError = "malformedRequest", - .expectedErrorCode = ClioError::RpcMalformedRequest, + .expectedErrorCode = rpc::ClioError::RpcMalformedRequest, .expectedErrorMessage = "Malformed request." }, VaultInfoParamTestCaseBundle{ @@ -137,7 +137,7 @@ generateTestValuesForParametersTest() "vault_id": "idk" })JSON", .expectedError = "malformedRequest", - .expectedErrorCode = ClioError::RpcMalformedRequest, + .expectedErrorCode = rpc::ClioError::RpcMalformedRequest, .expectedErrorMessage = "Malformed request." }, VaultInfoParamTestCaseBundle{