diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 402de67f..f06752fc 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -24,6 +24,8 @@ target_sources( handlers/AccountCurrencies.cpp handlers/AccountInfo.cpp handlers/AccountLines.cpp + handlers/AccountMPTokenIssuances.cpp + handlers/AccountMPTokens.cpp handlers/AccountNFTs.cpp handlers/AccountObjects.cpp handlers/AccountOffers.cpp diff --git a/src/rpc/RPCCenter.cpp b/src/rpc/RPCCenter.cpp index e7cba54c..58603f06 100644 --- a/src/rpc/RPCCenter.cpp +++ b/src/rpc/RPCCenter.cpp @@ -35,6 +35,8 @@ handledRpcs() "account_currencies", "account_info", "account_lines", + "account_mptoken_issuances", + "account_mptokens", "account_nfts", "account_objects", "account_offers", diff --git a/src/rpc/common/impl/HandlerProvider.cpp b/src/rpc/common/impl/HandlerProvider.cpp index 126dd7fc..c1a9ff68 100644 --- a/src/rpc/common/impl/HandlerProvider.cpp +++ b/src/rpc/common/impl/HandlerProvider.cpp @@ -31,6 +31,8 @@ #include "rpc/handlers/AccountCurrencies.hpp" #include "rpc/handlers/AccountInfo.hpp" #include "rpc/handlers/AccountLines.hpp" +#include "rpc/handlers/AccountMPTokenIssuances.hpp" +#include "rpc/handlers/AccountMPTokens.hpp" #include "rpc/handlers/AccountNFTs.hpp" #include "rpc/handlers/AccountObjects.hpp" #include "rpc/handlers/AccountOffers.hpp" @@ -85,6 +87,9 @@ ProductionHandlerProvider::ProductionHandlerProvider( {"account_currencies", {.handler = AccountCurrenciesHandler{backend}}}, {"account_info", {.handler = AccountInfoHandler{backend, amendmentCenter}}}, {"account_lines", {.handler = AccountLinesHandler{backend}}}, + {"account_mptoken_issuances", + {.handler = AccountMPTokenIssuancesHandler{backend}, .isClioOnly = true}}, // clio only + {"account_mptokens", {.handler = AccountMPTokensHandler{backend}, .isClioOnly = true}}, // clio only {"account_nfts", {.handler = AccountNFTsHandler{backend}}}, {"account_objects", {.handler = AccountObjectsHandler{backend}}}, {"account_offers", {.handler = AccountOffersHandler{backend}}}, diff --git a/src/rpc/handlers/AccountMPTokenIssuances.cpp b/src/rpc/handlers/AccountMPTokenIssuances.cpp new file mode 100644 index 00000000..5f22d955 --- /dev/null +++ b/src/rpc/handlers/AccountMPTokenIssuances.cpp @@ -0,0 +1,237 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "rpc/handlers/AccountMPTokenIssuances.hpp" + +#include "rpc/Errors.hpp" +#include "rpc/JS.hpp" +#include "rpc/RPCHelpers.hpp" +#include "rpc/common/Types.hpp" +#include "util/Assert.hpp" +#include "util/JsonUtils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace rpc { + +void +AccountMPTokenIssuancesHandler::addMPTokenIssuance( + std::vector& issuances, + ripple::SLE const& sle, + ripple::AccountID const& account +) +{ + MPTokenIssuanceResponse issuance; + + issuance.issuer = ripple::to_string(account); + issuance.sequence = sle.getFieldU32(ripple::sfSequence); + auto const flags = sle.getFieldU32(ripple::sfFlags); + + auto const setFlag = [&](std::optional& field, std::uint32_t mask) { + if ((flags & mask) != 0u) + field = true; + }; + + setFlag(issuance.mptLocked, ripple::lsfMPTLocked); + setFlag(issuance.mptCanLock, ripple::lsfMPTCanLock); + setFlag(issuance.mptRequireAuth, ripple::lsfMPTRequireAuth); + setFlag(issuance.mptCanEscrow, ripple::lsfMPTCanEscrow); + setFlag(issuance.mptCanTrade, ripple::lsfMPTCanTrade); + setFlag(issuance.mptCanTransfer, ripple::lsfMPTCanTransfer); + setFlag(issuance.mptCanClawback, ripple::lsfMPTCanClawback); + + if (sle.isFieldPresent(ripple::sfTransferFee)) + issuance.transferFee = sle.getFieldU16(ripple::sfTransferFee); + + if (sle.isFieldPresent(ripple::sfAssetScale)) + issuance.assetScale = sle.getFieldU8(ripple::sfAssetScale); + + if (sle.isFieldPresent(ripple::sfMaximumAmount)) + issuance.maximumAmount = sle.getFieldU64(ripple::sfMaximumAmount); + + if (sle.isFieldPresent(ripple::sfOutstandingAmount)) + issuance.outstandingAmount = sle.getFieldU64(ripple::sfOutstandingAmount); + + if (sle.isFieldPresent(ripple::sfLockedAmount)) + issuance.lockedAmount = sle.getFieldU64(ripple::sfLockedAmount); + + if (sle.isFieldPresent(ripple::sfMPTokenMetadata)) + issuance.mptokenMetadata = ripple::strHex(sle.getFieldVL(ripple::sfMPTokenMetadata)); + + if (sle.isFieldPresent(ripple::sfDomainID)) + issuance.domainID = ripple::strHex(sle.getFieldH256(ripple::sfDomainID)); + + issuances.push_back(issuance); +} + +AccountMPTokenIssuancesHandler::Result +AccountMPTokenIssuancesHandler::process(AccountMPTokenIssuancesHandler::Input const& input, Context const& ctx) const +{ + auto const range = sharedPtrBackend_->fetchLedgerRange(); + ASSERT(range.has_value(), "AccountMPTokenIssuances' ledger range must be available"); + auto const expectedLgrInfo = getLedgerHeaderFromHashOrSeq( + *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, range->maxSequence + ); + + if (!expectedLgrInfo.has_value()) + return Error{expectedLgrInfo.error()}; + + auto const& lgrInfo = expectedLgrInfo.value(); + auto const accountID = accountFromStringStrict(input.account); + auto const accountLedgerObject = + sharedPtrBackend_->fetchLedgerObject(ripple::keylet::account(*accountID).key, lgrInfo.seq, ctx.yield); + + if (not accountLedgerObject.has_value()) + return Error{Status{RippledError::rpcACT_NOT_FOUND}}; + + Output response; + response.issuances.reserve(input.limit); + + auto const addToResponse = [&](ripple::SLE const& sle) { + if (sle.getType() == ripple::ltMPTOKEN_ISSUANCE) { + addMPTokenIssuance(response.issuances, sle, *accountID); + } + }; + + auto const expectedNext = traverseOwnedNodes( + *sharedPtrBackend_, *accountID, lgrInfo.seq, input.limit, input.marker, ctx.yield, addToResponse + ); + + if (!expectedNext.has_value()) + return Error{expectedNext.error()}; + + auto const nextMarker = expectedNext.value(); + + response.account = input.account; + response.limit = input.limit; + + response.ledgerHash = ripple::strHex(lgrInfo.hash); + response.ledgerIndex = lgrInfo.seq; + + if (nextMarker.isNonZero()) + response.marker = nextMarker.toString(); + + return response; +} + +AccountMPTokenIssuancesHandler::Input +tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) +{ + auto input = AccountMPTokenIssuancesHandler::Input{}; + auto const& jsonObject = jv.as_object(); + + input.account = boost::json::value_to(jv.at(JS(account))); + + if (jsonObject.contains(JS(limit))) + input.limit = util::integralValueAs(jv.at(JS(limit))); + + if (jsonObject.contains(JS(marker))) + input.marker = boost::json::value_to(jv.at(JS(marker))); + + if (jsonObject.contains(JS(ledger_hash))) + input.ledgerHash = boost::json::value_to(jv.at(JS(ledger_hash))); + + if (jsonObject.contains(JS(ledger_index))) { + if (!jsonObject.at(JS(ledger_index)).is_string()) { + input.ledgerIndex = util::integralValueAs(jv.at(JS(ledger_index))); + } else if (jsonObject.at(JS(ledger_index)).as_string() != "validated") { + input.ledgerIndex = std::stoi(boost::json::value_to(jv.at(JS(ledger_index)))); + } + } + + return input; +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, AccountMPTokenIssuancesHandler::Output const& output) +{ + using boost::json::value_from; + + auto obj = boost::json::object{ + {JS(account), output.account}, + {JS(ledger_hash), output.ledgerHash}, + {JS(ledger_index), output.ledgerIndex}, + {JS(validated), output.validated}, + {JS(limit), output.limit}, + {"mpt_issuances", value_from(output.issuances)}, + }; + + if (output.marker.has_value()) + obj[JS(marker)] = *output.marker; + + jv = std::move(obj); +} + +void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + AccountMPTokenIssuancesHandler::MPTokenIssuanceResponse const& issuance +) +{ + auto obj = boost::json::object{ + {JS(issuer), issuance.issuer}, + {JS(sequence), issuance.sequence}, + }; + + auto const setIfPresent = [&](boost::json::string_view field, auto const& value) { + if (value.has_value()) { + obj[field] = *value; + } + }; + + setIfPresent("transfer_fee", issuance.transferFee); + setIfPresent("asset_scale", issuance.assetScale); + setIfPresent("maximum_amount", issuance.maximumAmount); + setIfPresent("outstanding_amount", issuance.outstandingAmount); + setIfPresent("locked_amount", issuance.lockedAmount); + setIfPresent("mptoken_metadata", issuance.mptokenMetadata); + setIfPresent("domain_id", issuance.domainID); + + setIfPresent("mpt_locked", issuance.mptLocked); + setIfPresent("mpt_can_lock", issuance.mptCanLock); + setIfPresent("mpt_require_auth", issuance.mptRequireAuth); + setIfPresent("mpt_can_escrow", issuance.mptCanEscrow); + setIfPresent("mpt_can_trade", issuance.mptCanTrade); + setIfPresent("mpt_can_transfer", issuance.mptCanTransfer); + setIfPresent("mpt_can_clawback", issuance.mptCanClawback); + + jv = std::move(obj); +} + +} // namespace rpc diff --git a/src/rpc/handlers/AccountMPTokenIssuances.hpp b/src/rpc/handlers/AccountMPTokenIssuances.hpp new file mode 100644 index 00000000..94122c08 --- /dev/null +++ b/src/rpc/handlers/AccountMPTokenIssuances.hpp @@ -0,0 +1,196 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "data/BackendInterface.hpp" +#include "rpc/Errors.hpp" +#include "rpc/JS.hpp" +#include "rpc/common/Checkers.hpp" +#include "rpc/common/MetaProcessors.hpp" +#include "rpc/common/Modifiers.hpp" +#include "rpc/common/Specs.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/common/Validators.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace rpc { + +/** + * @brief The account_mptoken_issuances method returns information about all MPTokenIssuance objects the account has + * created. + */ +class AccountMPTokenIssuancesHandler { + // dependencies + std::shared_ptr sharedPtrBackend_; + +public: + static constexpr auto kLIMIT_MIN = 10; + static constexpr auto kLIMIT_MAX = 400; + static constexpr auto kLIMIT_DEFAULT = 200; + + /** + * @brief A struct to hold data for one MPTokenIssuance response. + */ + struct MPTokenIssuanceResponse { + std::string issuer; + uint32_t sequence{}; + + std::optional transferFee{}; + std::optional assetScale{}; + + std::optional maximumAmount; + std::optional outstandingAmount; + std::optional lockedAmount; + std::optional mptokenMetadata; + std::optional domainID; + + std::optional mptLocked; + std::optional mptCanLock; + std::optional mptRequireAuth; + std::optional mptCanEscrow; + std::optional mptCanTrade; + std::optional mptCanTransfer; + std::optional mptCanClawback; + }; + + /** + * @brief A struct to hold the output data of the command. + */ + struct Output { + std::string account; + std::vector issuances; + std::string ledgerHash; + uint32_t ledgerIndex{}; + bool validated = true; + std::optional marker; + uint32_t limit{}; + }; + + /** + * @brief A struct to hold the input data for the command. + */ + struct Input { + std::string account; + std::optional ledgerHash; + std::optional ledgerIndex; + uint32_t limit = kLIMIT_DEFAULT; + std::optional marker; + }; + + using Result = HandlerReturnType; + + /** + * @brief Construct a new AccountMPTokenIssuancesHandler object. + * + * @param sharedPtrBackend The backend to use. + */ + AccountMPTokenIssuancesHandler(std::shared_ptr sharedPtrBackend) + : sharedPtrBackend_(std::move(sharedPtrBackend)) + { + } + + /** + * @brief Returns the API specification for the command. + * + * @param apiVersion The API version to return the spec for. + * @return The spec for the given API version. + */ + static RpcSpecConstRef + spec([[maybe_unused]] uint32_t apiVersion) + { + static auto const kRPC_SPEC = RpcSpec{ + {JS(account), + validation::Required{}, + meta::WithCustomError{ + validation::CustomValidators::accountValidator, Status(RippledError::rpcACT_MALFORMED) + }}, + {JS(ledger_hash), validation::CustomValidators::uint256HexStringValidator}, + {JS(limit), + validation::Type{}, + validation::Min(1u), + modifiers::Clamp{kLIMIT_MIN, kLIMIT_MAX}}, + {JS(ledger_index), validation::CustomValidators::ledgerIndexValidator}, + {JS(marker), validation::CustomValidators::accountMarkerValidator}, + {JS(ledger), check::Deprecated{}}, + }; + + return kRPC_SPEC; + } + + /** + * @brief Process the AccountMPTokenIssuances command. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @return The result of the operation. + */ + Result + process(Input const& input, Context const& ctx) const; + +private: + static void + addMPTokenIssuance( + std::vector& issuances, + ripple::SLE const& sle, + ripple::AccountID const& account + ); + +private: + /** + * @brief Convert the Output to a JSON object + * + * @param [out] jv The JSON object to convert to + * @param output The output to convert + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Output const& output); + + /** + * @brief Convert a JSON object to Input type + * + * @param jv The JSON object to convert + * @return Input parsed from the JSON object + */ + friend Input + tag_invoke(boost::json::value_to_tag, boost::json::value const& jv); + + /** + * @brief Convert the MPTokenIssuanceResponse to a JSON object + * + * @param [out] jv The JSON object to convert to + * @param issuance The MPTokenIssuance response to convert + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, MPTokenIssuanceResponse const& issuance); +}; + +} // namespace rpc diff --git a/src/rpc/handlers/AccountMPTokens.cpp b/src/rpc/handlers/AccountMPTokens.cpp new file mode 100644 index 00000000..9764aa72 --- /dev/null +++ b/src/rpc/handlers/AccountMPTokens.cpp @@ -0,0 +1,191 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "rpc/handlers/AccountMPTokens.hpp" + +#include "rpc/Errors.hpp" +#include "rpc/JS.hpp" +#include "rpc/RPCHelpers.hpp" +#include "rpc/common/Types.hpp" +#include "util/Assert.hpp" +#include "util/JsonUtils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace rpc { + +void +AccountMPTokensHandler::addMPToken(std::vector& mpts, ripple::SLE const& sle) +{ + MPTokenResponse token{}; + auto const flags = sle.getFieldU32(ripple::sfFlags); + + token.account = ripple::to_string(sle.getAccountID(ripple::sfAccount)); + token.MPTokenIssuanceID = ripple::strHex(sle.getFieldH192(ripple::sfMPTokenIssuanceID)); + token.MPTAmount = sle.getFieldU64(ripple::sfMPTAmount); + + if (sle.isFieldPresent(ripple::sfLockedAmount)) + token.lockedAmount = sle.getFieldU64(ripple::sfLockedAmount); + + auto const setFlag = [&](std::optional& field, std::uint32_t mask) { + if ((flags & mask) != 0u) + field = true; + }; + + setFlag(token.mptLocked, ripple::lsfMPTLocked); + setFlag(token.mptAuthorized, ripple::lsfMPTAuthorized); + + mpts.push_back(token); +} + +AccountMPTokensHandler::Result +AccountMPTokensHandler::process(AccountMPTokensHandler::Input const& input, Context const& ctx) const +{ + auto const range = sharedPtrBackend_->fetchLedgerRange(); + ASSERT(range.has_value(), "AccountMPTokens' ledger range must be available"); + auto const expectedLgrInfo = getLedgerHeaderFromHashOrSeq( + *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, range->maxSequence + ); + + if (!expectedLgrInfo.has_value()) + return Error{expectedLgrInfo.error()}; + + auto const& lgrInfo = expectedLgrInfo.value(); + auto const accountID = accountFromStringStrict(input.account); + auto const accountLedgerObject = + sharedPtrBackend_->fetchLedgerObject(ripple::keylet::account(*accountID).key, lgrInfo.seq, ctx.yield); + + if (not accountLedgerObject.has_value()) + return Error{Status{RippledError::rpcACT_NOT_FOUND}}; + + Output response; + response.mpts.reserve(input.limit); + + auto const addToResponse = [&](ripple::SLE const& sle) { + if (sle.getType() == ripple::ltMPTOKEN) { + addMPToken(response.mpts, sle); + } + }; + + auto const expectedNext = traverseOwnedNodes( + *sharedPtrBackend_, *accountID, lgrInfo.seq, input.limit, input.marker, ctx.yield, addToResponse + ); + + if (!expectedNext.has_value()) + return Error{expectedNext.error()}; + + auto const& nextMarker = expectedNext.value(); + + response.account = input.account; + response.limit = input.limit; + + response.ledgerHash = ripple::strHex(lgrInfo.hash); + response.ledgerIndex = lgrInfo.seq; + + if (nextMarker.isNonZero()) + response.marker = nextMarker.toString(); + + return response; +} + +AccountMPTokensHandler::Input +tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) +{ + AccountMPTokensHandler::Input input{}; + auto const& jsonObject = jv.as_object(); + + input.account = boost::json::value_to(jv.at(JS(account))); + + if (jsonObject.contains(JS(limit))) + input.limit = util::integralValueAs(jv.at(JS(limit))); + if (jsonObject.contains(JS(marker))) + input.marker = boost::json::value_to(jv.at(JS(marker))); + if (jsonObject.contains(JS(ledger_hash))) + input.ledgerHash = boost::json::value_to(jv.at(JS(ledger_hash))); + if (jsonObject.contains(JS(ledger_index))) { + if (!jv.at(JS(ledger_index)).is_string()) { + input.ledgerIndex = util::integralValueAs(jv.at(JS(ledger_index))); + } else if (boost::json::value_to(jv.at(JS(ledger_index))) != "validated") { + input.ledgerIndex = std::stoi(boost::json::value_to(jv.at(JS(ledger_index)))); + } + } + + return input; +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, AccountMPTokensHandler::Output const& output) +{ + auto obj = boost::json::object{ + {JS(account), output.account}, + {JS(ledger_hash), output.ledgerHash}, + {JS(ledger_index), output.ledgerIndex}, + {JS(validated), output.validated}, + {JS(limit), output.limit}, + {"mptokens", boost::json::value_from(output.mpts)}, + }; + + if (output.marker.has_value()) + obj[JS(marker)] = *output.marker; + + jv = std::move(obj); +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, AccountMPTokensHandler::MPTokenResponse const& mptoken) +{ + auto obj = boost::json::object{ + {JS(account), mptoken.account}, + {JS(mpt_issuance_id), mptoken.MPTokenIssuanceID}, + {JS(mpt_amount), mptoken.MPTAmount}, + }; + + auto const setIfPresent = [&](boost::json::string_view field, auto const& value) { + if (value.has_value()) { + obj[field] = *value; + } + }; + + setIfPresent("locked_amount", mptoken.lockedAmount); + setIfPresent("mpt_locked", mptoken.mptLocked); + setIfPresent("mpt_authorized", mptoken.mptAuthorized); + + jv = std::move(obj); +} + +} // namespace rpc diff --git a/src/rpc/handlers/AccountMPTokens.hpp b/src/rpc/handlers/AccountMPTokens.hpp new file mode 100644 index 00000000..eef74ce2 --- /dev/null +++ b/src/rpc/handlers/AccountMPTokens.hpp @@ -0,0 +1,178 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "data/BackendInterface.hpp" +#include "rpc/Errors.hpp" +#include "rpc/JS.hpp" +#include "rpc/common/Checkers.hpp" +#include "rpc/common/MetaProcessors.hpp" +#include "rpc/common/Modifiers.hpp" +#include "rpc/common/Specs.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/common/Validators.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace rpc { + +/** + * @brief The account_mptokens method returns information about the MPTokens the account currently holds. + */ +class AccountMPTokensHandler { + // dependencies + std::shared_ptr sharedPtrBackend_; + +public: + static constexpr auto kLIMIT_MIN = 10; + static constexpr auto kLIMIT_MAX = 400; + static constexpr auto kLIMIT_DEFAULT = 200; + + /** + * @brief A struct to hold data for one MPToken response. + */ + struct MPTokenResponse { + std::string account; + std::string MPTokenIssuanceID; + uint64_t MPTAmount{}; + std::optional lockedAmount; + + std::optional mptLocked; + std::optional mptAuthorized; + }; + + /** + * @brief A struct to hold the output data of the command. + */ + struct Output { + std::string account; + std::vector mpts; + std::string ledgerHash; + uint32_t ledgerIndex{}; + bool validated = true; + std::optional marker; + uint32_t limit{}; + }; + + /** + * @brief A struct to hold the input data for the command. + */ + struct Input { + std::string account; + std::optional ledgerHash; + std::optional ledgerIndex; + uint32_t limit = kLIMIT_DEFAULT; + std::optional marker; + }; + + using Result = HandlerReturnType; + + /** + * @brief Construct a new AccountMPTokensHandler object. + * + * @param sharedPtrBackend The backend to use. + */ + AccountMPTokensHandler(std::shared_ptr sharedPtrBackend) + : sharedPtrBackend_(std::move(sharedPtrBackend)) + { + } + + /** + * @brief Returns the API specification for the command. + * + * @param apiVersion The API version to return the spec for. + * @return The spec for the given API version. + */ + static RpcSpecConstRef + spec([[maybe_unused]] uint32_t apiVersion) + { + static auto const kRPC_SPEC = RpcSpec{ + {JS(account), + validation::Required{}, + meta::WithCustomError{ + validation::CustomValidators::accountValidator, Status(RippledError::rpcACT_MALFORMED) + }}, + {JS(ledger_hash), validation::CustomValidators::uint256HexStringValidator}, + {JS(limit), + validation::Type{}, + validation::Min(1u), + modifiers::Clamp{kLIMIT_MIN, kLIMIT_MAX}}, + {JS(ledger_index), validation::CustomValidators::ledgerIndexValidator}, + {JS(marker), validation::CustomValidators::accountMarkerValidator}, + {JS(ledger), check::Deprecated{}}, + }; + + return kRPC_SPEC; + } + + /** + * @brief Process the AccountMPTokens command. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @return The result of the operation. + */ + Result + process(Input const& input, Context const& ctx) const; + +private: + static void + addMPToken(std::vector& mpts, ripple::SLE const& sle); + +private: + /** + * @brief Convert the Output to a JSON object + * + * @param [out] jv The JSON object to convert to + * @param output The output to convert + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Output const& output); + + /** + * @brief Convert a JSON object to Input type + * + * @param jv The JSON object to convert + * @return Input parsed from the JSON object + */ + friend Input + tag_invoke(boost::json::value_to_tag, boost::json::value const& jv); + + /** + * @brief Convert the MPTokenResponse to a JSON object + * + * @param [out] jv The JSON object to convert to + * @param mptoken The MPToken response to convert + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, MPTokenResponse const& mptoken); +}; + +} // namespace rpc diff --git a/tests/common/util/TestObject.cpp b/tests/common/util/TestObject.cpp index 4b56b2ef..7f28a975 100644 --- a/tests/common/util/TestObject.cpp +++ b/tests/common/util/TestObject.cpp @@ -1442,38 +1442,69 @@ createLptCurrency(std::string_view assetCurrency, std::string_view asset2Currenc } ripple::STObject -createMptIssuanceObject(std::string_view accountId, std::uint32_t seq, std::string_view metadata) +createMptIssuanceObject( + std::string_view accountId, + std::uint32_t seq, + std::optional metadata, + std::uint32_t flags, + std::uint64_t outstandingAmount, + std::optional transferFee, + std::optional assetScale, + std::optional maxAmount, + std::optional lockedAmount, + std::optional domainId +) { ripple::STObject mptIssuance(ripple::sfLedgerEntry); mptIssuance.setAccountID(ripple::sfIssuer, getAccountIdWithString(accountId)); mptIssuance.setFieldU16(ripple::sfLedgerEntryType, ripple::ltMPTOKEN_ISSUANCE); - mptIssuance.setFieldU32(ripple::sfFlags, 0); mptIssuance.setFieldU32(ripple::sfSequence, seq); mptIssuance.setFieldU64(ripple::sfOwnerNode, 0); mptIssuance.setFieldH256(ripple::sfPreviousTxnID, ripple::uint256{}); + mptIssuance.setFieldU32(ripple::sfFlags, flags); mptIssuance.setFieldU32(ripple::sfPreviousTxnLgrSeq, 0); - mptIssuance.setFieldU64(ripple::sfMaximumAmount, 0); - mptIssuance.setFieldU64(ripple::sfOutstandingAmount, 0); - ripple::Slice const sliceMetadata(metadata.data(), metadata.size()); - mptIssuance.setFieldVL(ripple::sfMPTokenMetadata, sliceMetadata); + mptIssuance.setFieldU64(ripple::sfOutstandingAmount, outstandingAmount); + + if (transferFee.has_value()) + mptIssuance.setFieldU16(ripple::sfTransferFee, *transferFee); + if (assetScale.has_value()) + mptIssuance.setFieldU8(ripple::sfAssetScale, *assetScale); + if (maxAmount.has_value()) + mptIssuance.setFieldU64(ripple::sfMaximumAmount, *maxAmount); + if (lockedAmount.has_value()) + mptIssuance.setFieldU64(ripple::sfLockedAmount, *lockedAmount); + if (metadata.has_value()) { + ripple::Slice const sliceMetadata(metadata->data(), metadata->size()); + mptIssuance.setFieldVL(ripple::sfMPTokenMetadata, sliceMetadata); + } + if (domainId.has_value()) + mptIssuance.setFieldH256(ripple::sfDomainID, ripple::uint256{*domainId}); return mptIssuance; } ripple::STObject -createMpTokenObject(std::string_view accountId, ripple::uint192 issuanceID, std::uint64_t mptAmount) +createMpTokenObject( + std::string_view accountId, + ripple::uint192 issuanceID, + std::uint64_t mptAmount, + std::uint32_t flags, + std::optional lockedAmount +) { ripple::STObject mptoken(ripple::sfLedgerEntry); mptoken.setAccountID(ripple::sfAccount, getAccountIdWithString(accountId)); mptoken[ripple::sfMPTokenIssuanceID] = issuanceID; mptoken.setFieldU16(ripple::sfLedgerEntryType, ripple::ltMPTOKEN); - mptoken.setFieldU32(ripple::sfFlags, 0); + mptoken.setFieldU32(ripple::sfFlags, flags); mptoken.setFieldU64(ripple::sfOwnerNode, 0); mptoken.setFieldH256(ripple::sfPreviousTxnID, ripple::uint256{}); mptoken.setFieldU32(ripple::sfPreviousTxnLgrSeq, 0); if (mptAmount != 0u) mptoken.setFieldU64(ripple::sfMPTAmount, mptAmount); + if (lockedAmount.has_value()) + mptoken.setFieldU64(ripple::sfLockedAmount, *lockedAmount); return mptoken; } diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index d9aa1489..fd811c59 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -451,10 +451,27 @@ createDidObject(std::string_view accountId, std::string_view didDoc, std::string createLptCurrency(std::string_view assetCurrency, std::string_view asset2Currency); [[nodiscard]] ripple::STObject -createMptIssuanceObject(std::string_view accountId, std::uint32_t seq, std::string_view metadata); +createMptIssuanceObject( + std::string_view accountId, + std::uint32_t seq, + std::optional metadata = std::nullopt, + std::uint32_t flags = 0, + std::uint64_t outstandingAmount = 0, + std::optional transferFee = std::nullopt, + std::optional assetScale = std::nullopt, + std::optional maxAmount = std::nullopt, + std::optional lockedAmount = std::nullopt, + std::optional domainId = std::nullopt +); [[nodiscard]] ripple::STObject -createMpTokenObject(std::string_view accountId, ripple::uint192 issuanceID, std::uint64_t mptAmount = 1); +createMpTokenObject( + std::string_view accountId, + ripple::uint192 issuanceID, + std::uint64_t mptAmount = 1, + std::uint32_t flags = 0, + std::optional lockedAmount = std::nullopt +); [[nodiscard]] ripple::STObject createMPTIssuanceCreateTx(std::string_view accountId, uint32_t fee, uint32_t seq); diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index b059f4d6..636eab68 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -100,6 +100,8 @@ target_sources( rpc/handlers/AccountCurrenciesTests.cpp rpc/handlers/AccountInfoTests.cpp rpc/handlers/AccountLinesTests.cpp + rpc/handlers/AccountMPTokenIssuancesTests.cpp + rpc/handlers/AccountMPTokensTests.cpp rpc/handlers/AccountNFTsTests.cpp rpc/handlers/AccountObjectsTests.cpp rpc/handlers/AccountOffersTests.cpp diff --git a/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp b/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp new file mode 100644 index 00000000..8b5e5c6a --- /dev/null +++ b/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp @@ -0,0 +1,830 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "data/Types.hpp" +#include "rpc/Errors.hpp" +#include "rpc/common/AnyHandler.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/handlers/AccountMPTokenIssuances.hpp" +#include "util/HandlerBaseTestFixture.hpp" +#include "util/NameGenerator.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace rpc; +using namespace data; +namespace json = boost::json; +using namespace testing; + +namespace { + +constexpr auto kLEDGER_HASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr auto kACCOUNT = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr auto kISSUANCE_INDEX1 = "A6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; +constexpr auto kISSUANCE_INDEX2 = "B6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322"; + +// unique values for issuance1 +constexpr uint64_t kISSUANCE1_MAX_AMOUNT = 10000; +constexpr uint64_t kISSUANCE1_OUTSTANDING_AMOUNT = 5000; +constexpr uint8_t kISSUANCE1_ASSET_SCALE = 2; + +// unique values for issuance2 +constexpr uint64_t kISSUANCE2_MAX_AMOUNT = 20000; +constexpr uint64_t kISSUANCE2_OUTSTANDING_AMOUNT = 800; +constexpr uint64_t kISSUANCE2_LOCKED_AMOUNT = 100; +constexpr uint16_t kISSUANCE2_TRANSFER_FEE = 5; +constexpr auto kISSUANCE2_METADATA = "test-meta"; +constexpr auto kISSUANCE2_METADATA_HEX = "746573742D6D657461"; +constexpr auto kISSUANCE2_DOMAIN_ID_HEX = "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; + +// define expected JSON for mpt issuances +auto const kISSUANCE_OUT1 = fmt::format( + R"JSON({{ + "issuer": "{}", + "sequence": 1, + "maximum_amount": {}, + "outstanding_amount": {}, + "asset_scale": {}, + "mpt_can_escrow": true, + "mpt_can_trade": true, + "mpt_require_auth": true, + "mpt_can_transfer": true + }})JSON", + kACCOUNT, + kISSUANCE1_MAX_AMOUNT, + kISSUANCE1_OUTSTANDING_AMOUNT, + kISSUANCE1_ASSET_SCALE +); + +auto const kISSUANCE_OUT2 = fmt::format( + R"JSON({{ + "issuer": "{}", + "sequence": 2, + "maximum_amount": {}, + "outstanding_amount": {}, + "locked_amount": {}, + "transfer_fee": {}, + "mptoken_metadata": "{}", + "domain_id": "{}", + "mpt_can_lock": true, + "mpt_locked": true, + "mpt_can_clawback": true + }})JSON", + kACCOUNT, + kISSUANCE2_MAX_AMOUNT, + kISSUANCE2_OUTSTANDING_AMOUNT, + kISSUANCE2_LOCKED_AMOUNT, + kISSUANCE2_TRANSFER_FEE, + kISSUANCE2_METADATA_HEX, + kISSUANCE2_DOMAIN_ID_HEX +); + +} // namespace + +struct RPCAccountMPTokenIssuancesHandlerTest : HandlerBaseTest { + RPCAccountMPTokenIssuancesHandlerTest() + { + backend_->setRange(10, 30); + } +}; + +struct AccountMPTokenIssuancesParamTestCaseBundle { + std::string testName; + std::string testJson; + std::string expectedError; + std::string expectedErrorMessage; +}; + +struct AccountMPTokenIssuancesParameterTest : RPCAccountMPTokenIssuancesHandlerTest, + WithParamInterface {}; + +// generate values for invalid params test +static auto +generateTestValuesForInvalidParamsTest() +{ + return std::vector{ + {"NonHexLedgerHash", + fmt::format(R"JSON({{ "account": "{}", "ledger_hash": "xxx" }})JSON", kACCOUNT), + "invalidParams", + "ledger_hashMalformed"}, + {"NonStringLedgerHash", + fmt::format(R"JSON({{ "account": "{}", "ledger_hash": 123 }})JSON", kACCOUNT), + "invalidParams", + "ledger_hashNotString"}, + {"InvalidLedgerIndexString", + fmt::format(R"JSON({{ "account": "{}", "ledger_index": "notvalidated" }})JSON", kACCOUNT), + "invalidParams", + "ledgerIndexMalformed"}, + {"MarkerNotString", + fmt::format(R"JSON({{ "account": "{}", "marker": 9 }})JSON", kACCOUNT), + "invalidParams", + "markerNotString"}, + {"InvalidMarkerContent", + fmt::format(R"JSON({{ "account": "{}", "marker": "123invalid" }})JSON", kACCOUNT), + "invalidParams", + "Malformed cursor."}, + {"AccountMissing", R"JSON({ "limit": 10 })JSON", "invalidParams", "Required field 'account' missing"}, + {"AccountNotString", R"JSON({ "account": 123 })JSON", "actMalformed", "Account malformed."}, + {"AccountMalformed", + R"JSON({ "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jp" })JSON", + "actMalformed", + "Account malformed."}, + {"LimitNotInteger", + fmt::format(R"JSON({{ "account": "{}", "limit": "t" }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitNegative", + fmt::format(R"JSON({{ "account": "{}", "limit": -1 }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitZero", + fmt::format(R"JSON({{ "account": "{}", "limit": 0 }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitTypeInvalid", + fmt::format(R"JSON({{ "account": "{}", "limit": true }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."} + }; +} + +INSTANTIATE_TEST_SUITE_P( + RPCAccountMPTokenIssuancesInvalidParamsGroup, + AccountMPTokenIssuancesParameterTest, + ValuesIn(generateTestValuesForInvalidParamsTest()), + tests::util::kNAME_GENERATOR +); + +// test invalid params bundle +TEST_P(AccountMPTokenIssuancesParameterTest, InvalidParams) +{ + auto const testBundle = GetParam(); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const req = json::parse(testBundle.testJson); + auto const output = handler.process(req, Context{yield}); + ASSERT_FALSE(output); + + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), testBundle.expectedError); + EXPECT_EQ(err.at("error_message").as_string(), testBundle.expectedErrorMessage); + }); +} + +// ledger not found via hash +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, NonExistLedgerViaLedgerHash) +{ + // mock fetchLedgerByHash return empty + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)) + .WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + 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(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// ledger not found via string index +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, NonExistLedgerViaLedgerStringIndex) +{ + // mock fetchLedgerBySequence return empty + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": "4" + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// ledger not found via int index +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, NonExistLedgerViaLedgerIntIndex) +{ + // mock fetchLedgerBySequence return empty + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": 4 + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// ledger not found via hash (seq > max) +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LedgerSeqOutOfRangeByHash) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 31); + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)).WillOnce(Return(ledgerHeader)); + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// ledger not found via index (seq > max) +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LedgerSeqOutOfRangeByIndex) +{ + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": "31" + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// account not exist +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, NonExistAccount) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)).WillOnce(Return(ledgerHeader)); + // fetch account object return empty + EXPECT_CALL(*backend_, doFetchLedgerObject).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "actNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "Account not found."); + }); +} + +// fetch mptoken issuances via account successfully +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, DefaultParameters) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + // return non-empty account + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + // return two mptoken issuance objects + ripple::STObject const ownerDir = createOwnerDirLedgerObject( + {ripple::uint256{kISSUANCE_INDEX1}, ripple::uint256{kISSUANCE_INDEX2}}, kISSUANCE_INDEX1 + ); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + + // mocking mptoken issuance ledger objects + std::vector bbs; + auto const issuance1 = createMptIssuanceObject( + kACCOUNT, + 1, + std::nullopt, + ripple::lsfMPTCanTrade | ripple::lsfMPTRequireAuth | ripple::lsfMPTCanTransfer | ripple::lsfMPTCanEscrow, + kISSUANCE1_OUTSTANDING_AMOUNT, + std::nullopt, + kISSUANCE1_ASSET_SCALE, + kISSUANCE1_MAX_AMOUNT + ); + + auto const issuance2 = createMptIssuanceObject( + kACCOUNT, + 2, + kISSUANCE2_METADATA, + ripple::lsfMPTLocked | ripple::lsfMPTCanLock | ripple::lsfMPTCanClawback, + kISSUANCE2_OUTSTANDING_AMOUNT, + kISSUANCE2_TRANSFER_FEE, + std::nullopt, + kISSUANCE2_MAX_AMOUNT, + kISSUANCE2_LOCKED_AMOUNT, + kISSUANCE2_DOMAIN_ID_HEX + ); + + bbs.push_back(issuance1.getSerializer().peekData()); + bbs.push_back(issuance2.getSerializer().peekData()); + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const expected = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mpt_issuances": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokenIssuancesHandler::kLIMIT_DEFAULT, + kISSUANCE_OUT1, + kISSUANCE_OUT2 + ); + auto const input = json::parse(fmt::format(R"JSON({{"account": "{}"}})JSON", kACCOUNT)); + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(expected), *output.result); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, UseLimit) +{ + constexpr int limit = 20; + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + std::vector indexes; + std::vector bbs; + + for (int i = 0; i < 50; ++i) { + indexes.emplace_back(ripple::uint256{kISSUANCE_INDEX1}); + auto const issuance = createMptIssuanceObject(kACCOUNT, i); + bbs.push_back(issuance.getSerializer().peekData()); + } + + ripple::STObject ownerDir = createOwnerDirLedgerObject(indexes, kISSUANCE_INDEX1); + ownerDir.setFieldU64(ripple::sfIndexNext, 99); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(7); + + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(3); + + runSpawn([this, limit](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + limit + ) + ); + + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + + auto const resultJson = (*output.result).as_object(); + EXPECT_EQ(resultJson.at("mpt_issuances").as_array().size(), limit); + ASSERT_TRUE(resultJson.contains("marker")); + EXPECT_THAT(boost::json::value_to(resultJson.at("marker")), EndsWith(",0")); + }); + + runSpawn([this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokenIssuancesHandler::kLIMIT_MIN - 1 + ) + ); + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("limit").as_uint64(), AccountMPTokenIssuancesHandler::kLIMIT_MIN); + }); + + runSpawn([this](auto yield) { + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{backend_}}; + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokenIssuancesHandler::kLIMIT_MAX + 1 + ) + ); + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("limit").as_uint64(), AccountMPTokenIssuancesHandler::kLIMIT_MAX); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MarkerOutput) +{ + constexpr auto kNEXT_PAGE = 99; + constexpr auto kLIMIT = 15; + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto ownerDirKk = ripple::keylet::ownerDir(account).key; + auto ownerDir2Kk = ripple::keylet::page(ripple::keylet::ownerDir(account), kNEXT_PAGE).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(3); + + std::vector indexes; + for (int i = 0; i < 10; ++i) { + indexes.emplace_back(kISSUANCE_INDEX1); + } + + std::vector bbs; + for (int i = 0; i < kLIMIT; ++i) { + bbs.push_back(createMptIssuanceObject(kACCOUNT, i).getSerializer().peekData()); + } + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + // mock the first directory page + ripple::STObject ownerDir1 = createOwnerDirLedgerObject(indexes, kISSUANCE_INDEX1); + ownerDir1.setFieldU64(ripple::sfIndexNext, kNEXT_PAGE); + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillByDefault(Return(ownerDir1.getSerializer().peekData())); + + // mock the second directory page + ripple::STObject ownerDir2 = createOwnerDirLedgerObject(indexes, kISSUANCE_INDEX2); + ownerDir2.setFieldU64(ripple::sfIndexNext, 0); + ON_CALL(*backend_, doFetchLedgerObject(ownerDir2Kk, _, _)) + .WillByDefault(Return(ownerDir2.getSerializer().peekData())); + + runSpawn([this, kLIMIT, kNEXT_PAGE](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + kLIMIT + ) + ); + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + auto const& resultJson = (*output.result).as_object(); + EXPECT_EQ( + boost::json::value_to(resultJson.at("marker")), + fmt::format("{},{}", kISSUANCE_INDEX1, kNEXT_PAGE) + ); + EXPECT_EQ(resultJson.at("mpt_issuances").as_array().size(), kLIMIT); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MarkerInput) +{ + constexpr auto kNEXT_PAGE = 99; + constexpr auto kLIMIT = 15; + + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto ownerDirKk = ripple::keylet::page(ripple::keylet::ownerDir(account), kNEXT_PAGE).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(3); + + std::vector bbs; + std::vector indexes; + for (int i = 0; i < kLIMIT; ++i) { + indexes.emplace_back(kISSUANCE_INDEX1); + bbs.push_back(createMptIssuanceObject(kACCOUNT, i).getSerializer().peekData()); + } + + ripple::STObject ownerDir = createOwnerDirLedgerObject(indexes, kISSUANCE_INDEX1); + ownerDir.setFieldU64(ripple::sfIndexNext, 0); + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this, kLIMIT, kNEXT_PAGE](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {}, + "marker": "{},{}" + }})JSON", + kACCOUNT, + kLIMIT, + kISSUANCE_INDEX1, + kNEXT_PAGE + ) + ); + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + + auto const& resultJson = (*output.result).as_object(); + EXPECT_TRUE(resultJson.if_contains("marker") == nullptr); + EXPECT_EQ(resultJson.at("mpt_issuances").as_array().size(), kLIMIT - 1); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LimitLessThanMin) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = createOwnerDirLedgerObject( + {ripple::uint256{kISSUANCE_INDEX1}, ripple::uint256{kISSUANCE_INDEX2}}, kISSUANCE_INDEX1 + ); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + std::vector bbs; + auto const issuance1 = createMptIssuanceObject( + kACCOUNT, + 1, + std::nullopt, + ripple::lsfMPTCanTrade | ripple::lsfMPTRequireAuth | ripple::lsfMPTCanTransfer | ripple::lsfMPTCanEscrow, + kISSUANCE1_OUTSTANDING_AMOUNT, + std::nullopt, + kISSUANCE1_ASSET_SCALE, + kISSUANCE1_MAX_AMOUNT + ); + + auto const issuance2 = createMptIssuanceObject( + kACCOUNT, + 2, + kISSUANCE2_METADATA, + ripple::lsfMPTLocked | ripple::lsfMPTCanLock | ripple::lsfMPTCanClawback, + kISSUANCE2_OUTSTANDING_AMOUNT, + kISSUANCE2_TRANSFER_FEE, + std::nullopt, + kISSUANCE2_MAX_AMOUNT, + kISSUANCE2_LOCKED_AMOUNT, + kISSUANCE2_DOMAIN_ID_HEX + ); + + bbs.push_back(issuance1.getSerializer().peekData()); + bbs.push_back(issuance2.getSerializer().peekData()); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokenIssuancesHandler::kLIMIT_MIN - 1 + ) + ); + + auto const correctOutput = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mpt_issuances": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokenIssuancesHandler::kLIMIT_MIN, + kISSUANCE_OUT1, + kISSUANCE_OUT2 + ); + + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(correctOutput), *output.result); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LimitMoreThanMax) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = createOwnerDirLedgerObject( + {ripple::uint256{kISSUANCE_INDEX1}, ripple::uint256{kISSUANCE_INDEX2}}, kISSUANCE_INDEX1 + ); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + std::vector bbs; + auto const issuance1 = createMptIssuanceObject( + kACCOUNT, + 1, + std::nullopt, + ripple::lsfMPTCanTrade | ripple::lsfMPTRequireAuth | ripple::lsfMPTCanTransfer | ripple::lsfMPTCanEscrow, + kISSUANCE1_OUTSTANDING_AMOUNT, + std::nullopt, + kISSUANCE1_ASSET_SCALE, + kISSUANCE1_MAX_AMOUNT + ); + + auto const issuance2 = createMptIssuanceObject( + kACCOUNT, + 2, + kISSUANCE2_METADATA, + ripple::lsfMPTLocked | ripple::lsfMPTCanLock | ripple::lsfMPTCanClawback, + kISSUANCE2_OUTSTANDING_AMOUNT, + kISSUANCE2_TRANSFER_FEE, + std::nullopt, + kISSUANCE2_MAX_AMOUNT, + kISSUANCE2_LOCKED_AMOUNT, + kISSUANCE2_DOMAIN_ID_HEX + ); + + bbs.push_back(issuance1.getSerializer().peekData()); + bbs.push_back(issuance2.getSerializer().peekData()); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokenIssuancesHandler::kLIMIT_MAX + 1 + ) + ); + + auto const correctOutput = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mpt_issuances": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokenIssuancesHandler::kLIMIT_MAX, + kISSUANCE_OUT1, + kISSUANCE_OUT2 + ); + + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(correctOutput), *output.result); + }); +} + +TEST_F(RPCAccountMPTokenIssuancesHandlerTest, EmptyResult) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = createOwnerDirLedgerObject({}, kISSUANCE_INDEX1); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}" + }})JSON", + kACCOUNT + ) + ); + auto handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("mpt_issuances").as_array().size(), 0); + }); +} diff --git a/tests/unit/rpc/handlers/AccountMPTokensTests.cpp b/tests/unit/rpc/handlers/AccountMPTokensTests.cpp new file mode 100644 index 00000000..fbfa9731 --- /dev/null +++ b/tests/unit/rpc/handlers/AccountMPTokensTests.cpp @@ -0,0 +1,747 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "data/Types.hpp" +#include "rpc/Errors.hpp" +#include "rpc/common/AnyHandler.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/handlers/AccountMPTokens.hpp" +#include "util/HandlerBaseTestFixture.hpp" +#include "util/NameGenerator.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace rpc; +using namespace data; +namespace json = boost::json; +using namespace testing; + +namespace { + +constexpr auto kLEDGER_HASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr auto kACCOUNT = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr auto kISSUANCE_ID_HEX = "00080000B43A1A953EADDB3314A73523789947C752044C49"; +constexpr auto kTOKEN_INDEX1 = "A6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; +constexpr auto kTOKEN_INDEX2 = "B6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322"; + +constexpr uint64_t kTOKEN1_AMOUNT = 500; +constexpr uint64_t kTOKEN1_LOCKED_AMOUNT = 50; +constexpr uint64_t kTOKEN2_AMOUNT = 250; + +// define expected JSON for mptokens +auto const kTOKEN_OUT1 = fmt::format( + R"JSON({{ + "account": "{}", + "mpt_issuance_id": "{}", + "mpt_amount": {}, + "locked_amount": {}, + "mpt_locked": true + }})JSON", + kACCOUNT, + kISSUANCE_ID_HEX, + kTOKEN1_AMOUNT, + kTOKEN1_LOCKED_AMOUNT +); + +auto const kTOKEN_OUT2 = fmt::format( + R"JSON({{ + "account": "{}", + "mpt_issuance_id": "{}", + "mpt_amount": {}, + "mpt_authorized": true + }})JSON", + kACCOUNT, + kISSUANCE_ID_HEX, + kTOKEN2_AMOUNT +); + +} // namespace + +struct RPCAccountMPTokensHandlerTest : HandlerBaseTest { + RPCAccountMPTokensHandlerTest() + { + backend_->setRange(10, 30); + } +}; + +struct AccountMPTokensParamTestCaseBundle { + std::string testName; + std::string testJson; + std::string expectedError; + std::string expectedErrorMessage; +}; + +struct AccountMPTokensParameterTest : RPCAccountMPTokensHandlerTest, + WithParamInterface {}; + +// generate values for invalid params test +static auto +generateTestValuesForInvalidParamsTest() +{ + return std::vector{ + {"NonHexLedgerHash", + fmt::format(R"JSON({{ "account": "{}", "ledger_hash": "xxx" }})JSON", kACCOUNT), + "invalidParams", + "ledger_hashMalformed"}, + {"NonStringLedgerHash", + fmt::format(R"JSON({{ "account": "{}", "ledger_hash": 123 }})JSON", kACCOUNT), + "invalidParams", + "ledger_hashNotString"}, + {"InvalidLedgerIndexString", + fmt::format(R"JSON({{ "account": "{}", "ledger_index": "notvalidated" }})JSON", kACCOUNT), + "invalidParams", + "ledgerIndexMalformed"}, + {"MarkerNotString", + fmt::format(R"JSON({{ "account": "{}", "marker": 9 }})JSON", kACCOUNT), + "invalidParams", + "markerNotString"}, + {"InvalidMarkerContent", + fmt::format(R"JSON({{ "account": "{}", "marker": "123invalid" }})JSON", kACCOUNT), + "invalidParams", + "Malformed cursor."}, + {"AccountMissing", R"JSON({ "limit": 10 })JSON", "invalidParams", "Required field 'account' missing"}, + {"AccountNotString", R"JSON({ "account": 123 })JSON", "actMalformed", "Account malformed."}, + {"AccountMalformed", + fmt::format(R"JSON({{ "account": "{}" }})JSON", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jp"), + "actMalformed", + "Account malformed."}, + {"LimitNotInteger", + fmt::format(R"JSON({{ "account": "{}", "limit": "t" }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitNegative", + fmt::format(R"JSON({{ "account": "{}", "limit": -1 }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitZero", + fmt::format(R"JSON({{ "account": "{}", "limit": 0 }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."}, + {"LimitTypeInvalid", + fmt::format(R"JSON({{ "account": "{}", "limit": true }})JSON", kACCOUNT), + "invalidParams", + "Invalid parameters."} + }; +} + +INSTANTIATE_TEST_SUITE_P( + RPCAccountMPTokensInvalidParamsGroup, + AccountMPTokensParameterTest, + ValuesIn(generateTestValuesForInvalidParamsTest()), + tests::util::kNAME_GENERATOR +); + +// test invalid params bundle +TEST_P(AccountMPTokensParameterTest, InvalidParams) +{ + auto const testBundle = GetParam(); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const req = json::parse(testBundle.testJson); + auto const output = handler.process(req, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), testBundle.expectedError); + EXPECT_EQ(err.at("error_message").as_string(), testBundle.expectedErrorMessage); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, NonExistLedgerViaLedgerHash) +{ + // mock fetchLedgerByHash to return empty + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)) + .WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, NonExistLedgerViaLedgerStringIndex) +{ + // mock fetchLedgerBySequence to return empty + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": "4" + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, NonExistLedgerViaLedgerIntIndex) +{ + // mock fetchLedgerBySequence to return empty + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": 4 + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, LedgerSeqOutOfRangeByHash) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 31); + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)).WillOnce(Return(ledgerHeader)); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, LedgerSeqOutOfRangeByIndex) +{ + // No need to check from db, call fetchLedgerBySequence 0 times + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_index": "31" + }})JSON", + kACCOUNT + ) + ); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, NonExistAccount) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerByHash(ripple::uint256{kLEDGER_HASH}, _)).WillOnce(Return(ledgerHeader)); + // fetch account object return empty + EXPECT_CALL(*backend_, doFetchLedgerObject).WillOnce(Return(std::optional{})); + + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}" + }})JSON", + kACCOUNT, + kLEDGER_HASH + ) + ); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountMPTokensHandler{backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_FALSE(output); + auto const err = rpc::makeError(output.result.error()); + EXPECT_EQ(err.at("error").as_string(), "actNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "Account not found."); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, DefaultParameters) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = + createOwnerDirLedgerObject({ripple::uint256{kTOKEN_INDEX1}, ripple::uint256{kTOKEN_INDEX2}}, kTOKEN_INDEX1); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + + std::vector bbs; + auto const token1 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN1_AMOUNT, ripple::lsfMPTLocked, kTOKEN1_LOCKED_AMOUNT + ); + + auto const token2 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN2_AMOUNT, ripple::lsfMPTAuthorized, std::nullopt + ); + + bbs.push_back(token1.getSerializer().peekData()); + bbs.push_back(token2.getSerializer().peekData()); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const expected = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mptokens": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokensHandler::kLIMIT_DEFAULT, + kTOKEN_OUT1, + kTOKEN_OUT2 + ); + auto const input = json::parse(fmt::format(R"JSON({{"account": "{}"}})JSON", kACCOUNT)); + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(expected), *output.result); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, UseLimit) +{ + constexpr int limit = 20; + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + std::vector indexes; + std::vector bbs; + + for (int i = 0; i < 50; ++i) { + indexes.emplace_back(ripple::uint256{kTOKEN_INDEX1}); + auto const token = createMpTokenObject(kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), i, 0, std::nullopt); + bbs.push_back(token.getSerializer().peekData()); + } + + ripple::STObject ownerDir = createOwnerDirLedgerObject(indexes, kTOKEN_INDEX1); + ownerDir.setFieldU64(ripple::sfIndexNext, 99); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(7); + + ON_CALL(*backend_, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*backend_, doFetchLedgerObjects).Times(3); + + runSpawn([this, limit](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + limit + ) + ); + + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + + auto const resultJson = (*output.result).as_object(); + EXPECT_EQ(resultJson.at("mptokens").as_array().size(), limit); + ASSERT_TRUE(resultJson.contains("marker")); + EXPECT_THAT(boost::json::value_to(resultJson.at("marker")), EndsWith(",0")); + }); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokensHandler::kLIMIT_MIN - 1 + ) + ); + + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("limit").as_uint64(), AccountMPTokensHandler::kLIMIT_MIN); + }); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokensHandler::kLIMIT_MAX + 1 + ) + ); + + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("limit").as_uint64(), AccountMPTokensHandler::kLIMIT_MAX); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, MarkerOutput) +{ + constexpr auto kNEXT_PAGE = 99; + constexpr auto kLIMIT = 15; + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto ownerDirKk = ripple::keylet::ownerDir(account).key; + auto ownerDir2Kk = ripple::keylet::page(ripple::keylet::ownerDir(account), kNEXT_PAGE).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + std::vector bbs; + for (int i = 0; i < kLIMIT; ++i) { + bbs.push_back(createMpTokenObject(kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), i, 0, std::nullopt) + .getSerializer() + .peekData()); + } + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + std::vector indexes1; + for (int i = 0; i < 10; ++i) { + indexes1.emplace_back(kTOKEN_INDEX1); + } + ripple::STObject ownerDir1 = createOwnerDirLedgerObject(indexes1, kTOKEN_INDEX1); + ownerDir1.setFieldU64(ripple::sfIndexNext, kNEXT_PAGE); + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillByDefault(Return(ownerDir1.getSerializer().peekData())); + + ripple::STObject ownerDir2 = createOwnerDirLedgerObject(indexes1, kTOKEN_INDEX2); + ownerDir2.setFieldU64(ripple::sfIndexNext, 0); + ON_CALL(*backend_, doFetchLedgerObject(ownerDir2Kk, _, _)) + .WillByDefault(Return(ownerDir2.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(3); + + runSpawn([this, kLIMIT, kNEXT_PAGE](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + kLIMIT + ) + ); + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + auto const& resultJson = (*output.result).as_object(); + EXPECT_EQ(resultJson.at("mptokens").as_array().size(), kLIMIT); + EXPECT_EQ( + boost::json::value_to(resultJson.at("marker")), fmt::format("{},{}", kTOKEN_INDEX1, kNEXT_PAGE) + ); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, MarkerInput) +{ + constexpr auto kNEXT_PAGE = 99; + constexpr auto kLIMIT = 15; + + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + auto ownerDirKk = ripple::keylet::page(ripple::keylet::ownerDir(account), kNEXT_PAGE).key; + + std::vector bbs; + std::vector indexes; + for (int i = 0; i < kLIMIT; ++i) { + indexes.emplace_back(kTOKEN_INDEX1); + bbs.push_back(createMpTokenObject(kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), i, 0, std::nullopt) + .getSerializer() + .peekData()); + } + + ripple::STObject ownerDir = createOwnerDirLedgerObject(indexes, kTOKEN_INDEX1); + ownerDir.setFieldU64(ripple::sfIndexNext, 0); + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(3); + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this, kLIMIT, kNEXT_PAGE](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {}, + "marker": "{},{}" + }})JSON", + kACCOUNT, + kLIMIT, + kTOKEN_INDEX1, + kNEXT_PAGE + ) + ); + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + auto const& resultJson = (*output.result).as_object(); + EXPECT_TRUE(resultJson.if_contains("marker") == nullptr); + EXPECT_EQ(resultJson.at("mptokens").as_array().size(), kLIMIT - 1); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, LimitLessThanMin) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = + createOwnerDirLedgerObject({ripple::uint256{kTOKEN_INDEX1}, ripple::uint256{kTOKEN_INDEX2}}, kTOKEN_INDEX1); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + std::vector bbs; + auto const token1 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN1_AMOUNT, ripple::lsfMPTLocked, kTOKEN1_LOCKED_AMOUNT + ); + bbs.push_back(token1.getSerializer().peekData()); + + auto const token2 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN2_AMOUNT, ripple::lsfMPTAuthorized, std::nullopt + ); + bbs.push_back(token2.getSerializer().peekData()); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokensHandler::kLIMIT_MIN - 1 + ) + ); + + auto const correctOutput = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mptokens": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokensHandler::kLIMIT_MIN, + kTOKEN_OUT1, + kTOKEN_OUT2 + ); + + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(correctOutput), *output.result); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, LimitMoreThanMax) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = + createOwnerDirLedgerObject({ripple::uint256{kTOKEN_INDEX1}, ripple::uint256{kTOKEN_INDEX2}}, kTOKEN_INDEX1); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + std::vector bbs; + auto const token1 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN1_AMOUNT, ripple::lsfMPTLocked, kTOKEN1_LOCKED_AMOUNT + ); + bbs.push_back(token1.getSerializer().peekData()); + + auto const token2 = createMpTokenObject( + kACCOUNT, ripple::uint192(kISSUANCE_ID_HEX), kTOKEN2_AMOUNT, ripple::lsfMPTAuthorized, std::nullopt + ); + bbs.push_back(token2.getSerializer().peekData()); + + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}", + "limit": {} + }})JSON", + kACCOUNT, + AccountMPTokensHandler::kLIMIT_MAX + 1 + ) + ); + + auto const correctOutput = fmt::format( + R"JSON({{ + "account": "{}", + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true, + "limit": {}, + "mptokens": [ + {}, + {} + ] + }})JSON", + kACCOUNT, + kLEDGER_HASH, + AccountMPTokensHandler::kLIMIT_MAX, + kTOKEN_OUT1, + kTOKEN_OUT2 + ); + + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(correctOutput), *output.result); + }); +} + +TEST_F(RPCAccountMPTokensHandlerTest, EmptyResult) +{ + auto ledgerHeader = createLedgerHeader(kLEDGER_HASH, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto account = getAccountIdWithString(kACCOUNT); + auto accountKk = ripple::keylet::account(account).key; + auto owneDirKk = ripple::keylet::ownerDir(account).key; + ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + ripple::STObject const ownerDir = createOwnerDirLedgerObject({}, kTOKEN_INDEX1); + ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)).WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(2); + + runSpawn([this](auto yield) { + auto const input = json::parse( + fmt::format( + R"JSON({{ + "account": "{}" + }})JSON", + kACCOUNT + ) + ); + auto handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ((*output.result).as_object().at("mptokens").as_array().size(), 0); + }); +} diff --git a/tests/unit/rpc/handlers/AllHandlerTests.cpp b/tests/unit/rpc/handlers/AllHandlerTests.cpp index 91d44557..531c259a 100644 --- a/tests/unit/rpc/handlers/AllHandlerTests.cpp +++ b/tests/unit/rpc/handlers/AllHandlerTests.cpp @@ -23,6 +23,8 @@ #include "rpc/handlers/AccountCurrencies.hpp" #include "rpc/handlers/AccountInfo.hpp" #include "rpc/handlers/AccountLines.hpp" +#include "rpc/handlers/AccountMPTokenIssuances.hpp" +#include "rpc/handlers/AccountMPTokens.hpp" #include "rpc/handlers/AccountNFTs.hpp" #include "rpc/handlers/AccountObjects.hpp" #include "rpc/handlers/AccountOffers.hpp" @@ -86,6 +88,8 @@ using AnyHandlerType = Types< AccountCurrenciesHandler, AccountInfoHandler, AccountLinesHandler, + AccountMPTokenIssuancesHandler, + AccountMPTokensHandler, AccountNFTsHandler, AccountObjectsHandler, AccountOffersHandler, diff --git a/tests/unit/rpc/handlers/LedgerEntryTests.cpp b/tests/unit/rpc/handlers/LedgerEntryTests.cpp index 6f2c7b3f..3934e2f0 100644 --- a/tests/unit/rpc/handlers/LedgerEntryTests.cpp +++ b/tests/unit/rpc/handlers/LedgerEntryTests.cpp @@ -3756,7 +3756,6 @@ TEST_F(RPCLedgerEntryTest, SyntheticMPTIssuanceID) "Issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "LedgerEntryType": "MPTokenIssuance", "MPTokenMetadata": "6D65746164617461", - "MaximumAmount": "0", "OutstandingAmount": "0", "OwnerNode": "0", "PreviousTxnID": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/tests/unit/rpc/handlers/VaultInfoTests.cpp b/tests/unit/rpc/handlers/VaultInfoTests.cpp index 6522249b..17bd794d 100644 --- a/tests/unit/rpc/handlers/VaultInfoTests.cpp +++ b/tests/unit/rpc/handlers/VaultInfoTests.cpp @@ -309,7 +309,6 @@ TEST_F(RPCVaultInfoHandlerTest, ValidVaultObjectQueryByVaultID) "Issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "LedgerEntryType": "MPTokenIssuance", "MPTokenMetadata": "6D65746164617461", - "MaximumAmount": "0", "OutstandingAmount": "0", "OwnerNode": "0", "PreviousTxnID": "0000000000000000000000000000000000000000000000000000000000000000", @@ -393,7 +392,6 @@ TEST_F(RPCVaultInfoHandlerTest, ValidVaultObjectQueryByOwnerAndSeq) "Issuer": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "LedgerEntryType": "MPTokenIssuance", "MPTokenMetadata": "6D65746164617461", - "MaximumAmount": "0", "OutstandingAmount": "0", "OwnerNode": "0", "PreviousTxnID": "0000000000000000000000000000000000000000000000000000000000000000",