feat: Add accounts filter to mpt_holders (#3180)

This commit is contained in:
Peter Chen
2026-09-18 09:07:55 -07:00
committed by GitHub
parent 35000059d9
commit 1621ac353c
8 changed files with 717 additions and 89 deletions

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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 <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/jss.h>
#include <optional>
#include <string>
#include <algorithm>
#include <vector>
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<xrpl::uint256> 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);

View File

@@ -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<rpc::spec::handlers::mpt_holders::Input> {
std::shared_ptr<BackendInterface> 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

View File

@@ -194,8 +194,10 @@ generateParameterBookOffersTestBundles()
.expectedErrorMessage = "Source currency is malformed."
},
ParameterTestBundle{
// A present-but-non-string currency is reported by validateTakerJSON as an
// expectedFieldError ('<field>.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": {

View File

@@ -16,6 +16,7 @@
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <cstddef>
#include <functional>
#include <optional>
#include <string>
@@ -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<Blob> 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<Blob> 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<Blob> 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<Blob> 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<Blob> 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<Blob> const objs = {
mptoken1.getSerializer().peekData(), mptoken2.getSerializer().peekData()
};
// The handler must query exactly the MPToken keys for the requested accounts, in order.
std::vector<xrpl::uint256> 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<Blob> 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<Blob> const objs = {mptoken.getSerializer().peekData()};
auto const key =
xrpl::keylet::mptoken(xrpl::uint192(kMptId), getAccountIdWithString(kHolder1Account)).key;
std::vector<xrpl::uint256> 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<Blob> 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<Blob> 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<Blob>{}));
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<Blob> const objs = {mptoken1.getSerializer().peekData()};
std::vector<xrpl::uint256> 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<Blob> const objs = {
mptoken1.getSerializer().peekData(), mptoken2.getSerializer().peekData()
};
std::vector<xrpl::uint256> 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<Blob> const mpts = {mptoken.getSerializer().peekData()};
ON_CALL(*backend_, fetchMPTHolders)
.WillByDefault(Return(MPTHoldersAndCursor{.mptokens = mpts, .cursor = {}}));

View File

@@ -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) {

View File

@@ -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{