diff --git a/cmake/deps/libxrpl.cmake b/cmake/deps/libxrpl.cmake index a68a52c7d..5f42d687d 100644 --- a/cmake/deps/libxrpl.cmake +++ b/cmake/deps/libxrpl.cmake @@ -1 +1 @@ -find_package(xrpl REQUIRED CONFIG) +find_package(xrpl-mpt REQUIRED CONFIG) diff --git a/conanfile.py b/conanfile.py index 22c58e772..11cd764de 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,7 +28,7 @@ class Clio(ConanFile): 'protobuf/3.21.9', 'grpc/1.50.1', 'openssl/1.1.1u', - 'xrpl/2.3.0-b4', + 'xrpl-mpt/2.3.0-b4', 'libbacktrace/cci.20210118' ] diff --git a/src/data/AmendmentCenter.hpp b/src/data/AmendmentCenter.hpp index 9b4c7c30c..710c9da65 100644 --- a/src/data/AmendmentCenter.hpp +++ b/src/data/AmendmentCenter.hpp @@ -127,6 +127,7 @@ struct Amendments { REGISTER(fixInnerObjTemplate2); REGISTER(fixNFTokenPageLinks); REGISTER(InvariantsV1_1); + REGISTER(MPTokensV1); // Obsolete but supported by libxrpl REGISTER(CryptoConditionsSuite); diff --git a/src/data/BackendInterface.hpp b/src/data/BackendInterface.hpp index 3a0dba87a..1f985de87 100644 --- a/src/data/BackendInterface.hpp +++ b/src/data/BackendInterface.hpp @@ -364,6 +364,25 @@ class BackendInterface { boost::asio::yield_context yield ) const = 0; + /** + * @brief Fetches all holders' balances for a MPTIssuanceID + * + * @param mptID MPTIssuanceID you wish you query. + * @param limit Paging limit. + * @param cursorIn Optional cursor to allow us to pick up from where we last left off. + * @param ledgerSequence The ledger sequence to fetch for + * @param yield Currently executing coroutine. + * @return std::vector of MPToken balances and an optional marker + */ + virtual MPTHoldersAndCursor + fetchMPTHolders( + ripple::uint192 const& mptID, + std::uint32_t const limit, + std::optional const& cursorIn, + std::uint32_t const ledgerSequence, + boost::asio::yield_context yield + ) const = 0; + /** * @brief Fetches a specific ledger object. * @@ -617,6 +636,14 @@ class BackendInterface { virtual void writeNFTTransactions(std::vector const& data) = 0; + /** + * @brief Write accounts that started holding onto a MPT. + * + * @param data A vector of MPT ID and account pairs + */ + virtual void + writeMPTHolders(std::vector const& data) = 0; + /** * @brief Write a new successor. * diff --git a/src/data/CassandraBackend.hpp b/src/data/CassandraBackend.hpp index 79c5776ba..c3ffdff51 100644 --- a/src/data/CassandraBackend.hpp +++ b/src/data/CassandraBackend.hpp @@ -547,6 +547,45 @@ class BasicCassandraBackend : public BackendInterface { return ret; } + MPTHoldersAndCursor + fetchMPTHolders( + ripple::uint192 const& mptID, + std::uint32_t const limit, + std::optional const& cursorIn, + std::uint32_t const ledgerSequence, + boost::asio::yield_context yield + ) const override + { + auto const holderEntries = executor_.read( + yield, schema_->selectMPTHolders, mptID, cursorIn.value_or(ripple::AccountID(0)), Limit{limit} + ); + + auto const& holderResults = holderEntries.value(); + if (not holderResults.hasRows()) { + LOG(log_.debug()) << "No rows returned"; + return {}; + } + + std::vector mptKeys; + std::optional cursor; + for (auto const [holder] : extract(holderResults)) { + mptKeys.push_back(ripple::keylet::mptoken(mptID, holder).key); + cursor = holder; + } + + auto mptObjects = doFetchLedgerObjects(mptKeys, ledgerSequence, yield); + + auto it = std::remove_if(mptObjects.begin(), mptObjects.end(), [](Blob const& mpt) { return mpt.size() == 0; }); + + mptObjects.erase(it, mptObjects.end()); + + ASSERT(mptKeys.size() <= limit, "Number of keys can't exceed the limit"); + if (mptKeys.size() == limit) + return {mptObjects, cursor}; + + return {mptObjects, {}}; + } + std::optional doFetchLedgerObject(ripple::uint256 const& key, std::uint32_t const sequence, boost::asio::yield_context yield) const override @@ -905,6 +944,16 @@ class BasicCassandraBackend : public BackendInterface { executor_.write(std::move(statements)); } + void + writeMPTHolders(std::vector const& data) override + { + std::vector statements; + for (auto [mptId, holder] : data) + statements.push_back(schema_->insertMPTHolder.bind(std::move(mptId), std::move(holder))); + + executor_.write(std::move(statements)); + } + void startWrites() const override { diff --git a/src/data/DBHelpers.hpp b/src/data/DBHelpers.hpp index 6d3a7c6cb..6f27de82a 100644 --- a/src/data/DBHelpers.hpp +++ b/src/data/DBHelpers.hpp @@ -172,6 +172,14 @@ struct NFTsData { } }; +/** + * @brief Represents an MPT and holder pair + */ +struct MPTHolderData { + ripple::uint192 mptID; + ripple::AccountID holder; +}; + /** * @brief Check whether the supplied object is an offer. * diff --git a/src/data/Types.hpp b/src/data/Types.hpp index 726d621c0..cb1eaa23b 100644 --- a/src/data/Types.hpp +++ b/src/data/Types.hpp @@ -233,6 +233,14 @@ struct NFTsAndCursor { std::optional cursor; }; +/** + * @brief Represents an array of MPTokens + */ +struct MPTHoldersAndCursor { + std::vector mptokens; + std::optional cursor; +}; + /** * @brief Stores a range of sequences as a min and max pair. */ diff --git a/src/data/cassandra/Schema.hpp b/src/data/cassandra/Schema.hpp index 3774558cd..d8afe779b 100644 --- a/src/data/cassandra/Schema.hpp +++ b/src/data/cassandra/Schema.hpp @@ -257,6 +257,19 @@ class Schema { qualifiedTableName(settingsProvider_.get(), "nf_token_transactions") )); + statements.emplace_back(fmt::format( + R"( + CREATE TABLE IF NOT EXISTS {} + ( + mpt_id blob, + holder blob, + PRIMARY KEY (mpt_id, holder) + ) + WITH CLUSTERING ORDER BY (holder ASC) + )", + qualifiedTableName(settingsProvider_.get(), "mp_token_holders") + )); + return statements; }(); @@ -393,6 +406,17 @@ class Schema { )); }(); + PreparedStatement insertMPTHolder = [this]() { + return handle_.get().prepare(fmt::format( + R"( + INSERT INTO {} + (mpt_id, holder) + VALUES (?, ?) + )", + qualifiedTableName(settingsProvider_.get(), "mp_token_holders") + )); + }(); + PreparedStatement insertLedgerHeader = [this]() { return handle_.get().prepare(fmt::format( R"( @@ -687,6 +711,20 @@ class Schema { )); }(); + PreparedStatement selectMPTHolders = [this]() { + return handle_.get().prepare(fmt::format( + R"( + SELECT holder + FROM {} + WHERE mpt_id = ? + AND holder > ? + ORDER BY holder ASC + LIMIT ? + )", + qualifiedTableName(settingsProvider_.get(), "mp_token_holders") + )); + }(); + PreparedStatement selectLedgerByHash = [this]() { return handle_.get().prepare(fmt::format( R"( diff --git a/src/data/cassandra/impl/Statement.hpp b/src/data/cassandra/impl/Statement.hpp index 43abf399a..8aa5ae253 100644 --- a/src/data/cassandra/impl/Statement.hpp +++ b/src/data/cassandra/impl/Statement.hpp @@ -106,9 +106,9 @@ class Statement : public ManagedObject { using UintByteTupleType = std::tuple; using ByteVectorType = std::vector; - if constexpr (std::is_same_v) { + if constexpr (std::is_same_v || std::is_same_v) { auto const rc = bindBytes(value.data(), value.size()); - throwErrorIfNeeded(rc, "Bind ripple::uint256"); + throwErrorIfNeeded(rc, "Bind ripple::base_uint"); } else if constexpr (std::is_same_v) { auto const rc = bindBytes(value.data(), value.size()); throwErrorIfNeeded(rc, "Bind ripple::AccountID"); diff --git a/src/etl/CMakeLists.txt b/src/etl/CMakeLists.txt index 74a68cb9f..011669c5d 100644 --- a/src/etl/CMakeLists.txt +++ b/src/etl/CMakeLists.txt @@ -10,6 +10,7 @@ target_sources( NetworkValidatedLedgers.cpp NFTHelpers.cpp Source.cpp + MPTHelpers.cpp impl/AmendmentBlockHandler.cpp impl/ForwardingSource.cpp impl/GrpcSource.cpp diff --git a/src/etl/MPTHelpers.cpp b/src/etl/MPTHelpers.cpp new file mode 100644 index 000000000..dd7126330 --- /dev/null +++ b/src/etl/MPTHelpers.cpp @@ -0,0 +1,78 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, 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/BackendInterface.hpp" +#include "data/DBHelpers.hpp" +#include "data/Types.hpp" + +#include +#include +#include +#include + +#include + +namespace etl { + +/** + * @brief Get the MPToken created from a transaction + * + * @param txMeta Transaction metadata + * @return MPT and holder account pair + */ +static std::optional +getMPTokenAuthorize(ripple::TxMeta const& txMeta) +{ + for (ripple::STObject const& node : txMeta.getNodes()) { + if (node.getFieldU16(ripple::sfLedgerEntryType) != ripple::ltMPTOKEN) + continue; + + if (node.getFName() == ripple::sfCreatedNode) { + auto const& newMPT = node.peekAtField(ripple::sfNewFields).downcast(); + return MPTHolderData{newMPT[ripple::sfMPTokenIssuanceID], newMPT.getAccountID(ripple::sfAccount)}; + } + } + return {}; +} + +std::optional +getMPTHolderFromTx(ripple::TxMeta const& txMeta, ripple::STTx const& sttx) +{ + if (txMeta.getResultTER() != ripple::tesSUCCESS || sttx.getTxnType() != ripple::TxType::ttMPTOKEN_AUTHORIZE) + return {}; + + return getMPTokenAuthorize(txMeta); +} + +std::optional +getMPTHolderFromObj(std::string const& key, std::string const& blob) +{ + ripple::STLedgerEntry const sle = + ripple::STLedgerEntry(ripple::SerialIter{blob.data(), blob.size()}, ripple::uint256::fromVoid(key.data())); + + if (sle.getFieldU16(ripple::sfLedgerEntryType) != ripple::ltMPTOKEN) + return {}; + + auto const mptIssuanceID = sle[ripple::sfMPTokenIssuanceID]; + auto const holder = sle.getAccountID(ripple::sfAccount); + + return MPTHolderData{mptIssuanceID, holder}; +} + +} // namespace etl diff --git a/src/etl/MPTHelpers.hpp b/src/etl/MPTHelpers.hpp new file mode 100644 index 000000000..2aabb99d7 --- /dev/null +++ b/src/etl/MPTHelpers.hpp @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, 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. +*/ +//============================================================================== + +/** @file */ +#pragma once + +#include "data/DBHelpers.hpp" + +#include +#include + +namespace etl { + +/** + * @brief Pull MPT data from TX via ETLService. + * + * @param txMeta Transaction metadata + * @param sttx The transaction + * @return The MPTIssuanceID and holder pair as a optional + */ +std::optional +getMPTHolderFromTx(ripple::TxMeta const& txMeta, ripple::STTx const& sttx); + +/** + * @brief Pull MPT data from ledger object via loadInitialLedger. + * + * @param key The owner key + * @param blob Object data as blob + * @return The MPTIssuanceID and holder pair as a optional + */ +std::optional +getMPTHolderFromObj(std::string const& key, std::string const& blob); + +} // namespace etl diff --git a/src/etl/impl/AsyncData.hpp b/src/etl/impl/AsyncData.hpp index 394e52f03..a4c134cb8 100644 --- a/src/etl/impl/AsyncData.hpp +++ b/src/etl/impl/AsyncData.hpp @@ -22,6 +22,7 @@ #include "data/BackendInterface.hpp" #include "data/Types.hpp" #include "etl/ETLHelpers.hpp" +#include "etl/MPTHelpers.hpp" #include "etl/NFTHelpers.hpp" #include "util/Assert.hpp" #include "util/log/Logger.hpp" @@ -154,6 +155,11 @@ class AsyncCallData { backend.writeSuccessor(std::move(lastKey_), request_.ledger().sequence(), std::string{obj.key()}); lastKey_ = obj.key(); backend.writeNFTs(getNFTDataFromObj(request_.ledger().sequence(), obj.key(), obj.data())); + + auto const maybeMPTHolder = getMPTHolderFromObj(obj.key(), obj.data()); + if (maybeMPTHolder) + backend.writeMPTHolders({*maybeMPTHolder}); + backend.writeLedgerObject( std::move(*obj.mutable_key()), request_.ledger().sequence(), std::move(*obj.mutable_data()) ); diff --git a/src/etl/impl/LedgerLoader.hpp b/src/etl/impl/LedgerLoader.hpp index 7fe716f4a..8f070ca43 100644 --- a/src/etl/impl/LedgerLoader.hpp +++ b/src/etl/impl/LedgerLoader.hpp @@ -22,6 +22,7 @@ #include "data/BackendInterface.hpp" #include "data/DBHelpers.hpp" #include "data/Types.hpp" +#include "etl/MPTHelpers.hpp" #include "etl/NFTHelpers.hpp" #include "etl/SystemState.hpp" #include "etl/impl/LedgerFetcher.hpp" @@ -55,6 +56,7 @@ struct FormattedTransactionsData { std::vector accountTxData; std::vector nfTokenTxData; std::vector nfTokensData; + std::vector mptHoldersData; }; namespace etl::impl { @@ -124,6 +126,10 @@ class LedgerLoader { if (maybeNFT) result.nfTokensData.push_back(*maybeNFT); + auto const maybeMPTHolder = getMPTHolderFromTx(txMeta, sttx); + if (maybeMPTHolder) + result.mptHoldersData.push_back(*maybeMPTHolder); + result.accountTxData.emplace_back(txMeta, sttx.getTransactionID()); static constexpr std::size_t KEY_SIZE = 32; std::string keyStr{reinterpret_cast(sttx.getTransactionID().data()), KEY_SIZE}; @@ -240,6 +246,7 @@ class LedgerLoader { backend_->writeAccountTransactions(std::move(insertTxResult.accountTxData)); backend_->writeNFTs(insertTxResult.nfTokensData); backend_->writeNFTTransactions(insertTxResult.nfTokenTxData); + backend_->writeMPTHolders(insertTxResult.mptHoldersData); } backend_->finishWrites(sequence); diff --git a/src/etl/impl/Transformer.hpp b/src/etl/impl/Transformer.hpp index ab4647e10..b018caddd 100644 --- a/src/etl/impl/Transformer.hpp +++ b/src/etl/impl/Transformer.hpp @@ -213,6 +213,7 @@ class Transformer { backend_->writeAccountTransactions(std::move(insertTxResultOp->accountTxData)); backend_->writeNFTs(insertTxResultOp->nfTokensData); backend_->writeNFTTransactions(insertTxResultOp->nfTokenTxData); + backend_->writeMPTHolders(insertTxResultOp->mptHoldersData); auto [success, duration] = ::util::timed>([&]() { return backend_->finishWrites(lgrInfo.seq); }); diff --git a/src/feed/impl/TransactionFeed.cpp b/src/feed/impl/TransactionFeed.cpp index b58304c62..af3de6ae6 100644 --- a/src/feed/impl/TransactionFeed.cpp +++ b/src/feed/impl/TransactionFeed.cpp @@ -203,6 +203,7 @@ TransactionFeed::pub( pubObj[JS(meta)] = rpc::toJson(*meta); rpc::insertDeliveredAmount(pubObj[JS(meta)].as_object(), tx, meta, txMeta.date); rpc::insertDeliverMaxAlias(pubObj[txKey].as_object(), version); + rpc::insertMPTIssuanceID(pubObj[JS(meta)].as_object(), tx, meta); pubObj[JS(type)] = "transaction"; pubObj[JS(validated)] = true; diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index b0de0f2df..ee8a66d93 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -33,6 +33,7 @@ target_sources( handlers/LedgerEntry.cpp handlers/LedgerIndex.cpp handlers/LedgerRange.cpp + handlers/MPTHolders.cpp handlers/NFTsByIssuer.cpp handlers/NFTBuyOffers.cpp handlers/NFTHistory.cpp diff --git a/src/rpc/RPCHelpers.cpp b/src/rpc/RPCHelpers.cpp index e1a474fd1..e0500df93 100644 --- a/src/rpc/RPCHelpers.cpp +++ b/src/rpc/RPCHelpers.cpp @@ -259,6 +259,7 @@ toExpandedJson( auto metaJson = toJson(*meta); insertDeliveredAmount(metaJson, txn, meta, blobs.date); insertDeliverMaxAlias(txnJson, apiVersion); + insertMPTIssuanceID(metaJson, txn, meta); if (nftEnabled == NFTokenjson::ENABLE) { Json::Value nftJson; @@ -314,6 +315,67 @@ insertDeliveredAmount( return false; } +/** + * @brief Get the delivered amount + * + * @param meta The metadata + * @return The mpt_issuance_id or std::nullopt if not available + */ +static std::optional +getMPTIssuanceID(std::shared_ptr const& meta) +{ + ripple::TxMeta const& transactionMeta = *meta; + + for (ripple::STObject const& node : transactionMeta.getNodes()) { + if (node.getFieldU16(ripple::sfLedgerEntryType) != ripple::ltMPTOKEN_ISSUANCE || + node.getFName() != ripple::sfCreatedNode) + continue; + + auto const& mptNode = node.peekAtField(ripple::sfNewFields).downcast(); + return ripple::makeMptID(mptNode[ripple::sfSequence], mptNode[ripple::sfIssuer]); + } + + return {}; +} + +/** + * @brief Check if transaction has a new MPToken created + * + * @param txn The transaction + * @param meta The metadata + * @return true if the transaction can have a mpt_issuance_id + */ +static bool +canHaveMPTIssuanceID(std::shared_ptr const& txn, std::shared_ptr const& meta) +{ + if (txn->getTxnType() != ripple::ttMPTOKEN_ISSUANCE_CREATE) + return false; + + if (meta->getResultTER() != ripple::tesSUCCESS) + return false; + + return true; +} + +bool +insertMPTIssuanceID( + boost::json::object& metaJson, + std::shared_ptr const& txn, + std::shared_ptr const& meta +) +{ + if (!canHaveMPTIssuanceID(txn, meta)) + return false; + + if (auto const id = getMPTIssuanceID(meta)) { + metaJson[JS(mpt_issuance_id)] = ripple::to_string(*id); + return true; + } + + assert(false); + return false; +} + void insertDeliverMaxAlias(boost::json::object& txJson, std::uint32_t const apiVersion) { @@ -950,7 +1012,7 @@ accountHolds( auto const blob = backend.fetchLedgerObject(key, sequence, yield); if (!blob) { - amount.clear({currency, issuer}); + amount.clear(ripple::Issue{currency, issuer}); return amount; } @@ -958,7 +1020,7 @@ accountHolds( ripple::SLE const sle{it, key}; if (zeroIfFrozen && isFrozen(backend, sequence, account, currency, issuer, yield)) { - amount.clear(ripple::Issue(currency, issuer)); + amount.clear(ripple::Issue{currency, issuer}); } else { amount = sle.getFieldAmount(ripple::sfBalance); if (account > issuer) { diff --git a/src/rpc/RPCHelpers.hpp b/src/rpc/RPCHelpers.hpp index 7fe2a5b69..1ffd52cb8 100644 --- a/src/rpc/RPCHelpers.hpp +++ b/src/rpc/RPCHelpers.hpp @@ -191,6 +191,21 @@ insertDeliveredAmount( uint32_t date ); +/** + * @brief Add "mpt_issuance_id" into MPTokenIssuanceCreate transaction json. + * + * @param metaJson The metadata json object to add "MPTokenIssuanceID" + * @param txn The transaction object + * @param meta The metadata object + * @return true if the "mpt_issuance_id" is added to the metadata json object + */ +bool +insertMPTIssuanceID( + boost::json::object& metaJson, + std::shared_ptr const& txn, + std::shared_ptr const& meta +); + /** * @brief Convert STBase object to JSON * diff --git a/src/rpc/common/Validators.cpp b/src/rpc/common/Validators.cpp index 71f41b41a..d2ab1d380 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -89,16 +89,19 @@ checkIsU32Numeric(std::string_view sv) return ec == std::errc(); } -CustomValidator CustomValidators::Uint256HexStringValidator = +CustomValidator CustomValidators::Uint160HexStringValidator = CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { - if (!value.is_string()) - return Error{Status{RippledError::rpcINVALID_PARAMS, std::string(key) + "NotString"}}; + return makeHexStringValidator(value, key); + }}; - ripple::uint256 ledgerHash; - if (!ledgerHash.parseHex(boost::json::value_to(value))) - return Error{Status{RippledError::rpcINVALID_PARAMS, std::string(key) + "Malformed"}}; +CustomValidator CustomValidators::Uint192HexStringValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + return makeHexStringValidator(value, key); + }}; - return MaybeError{}; +CustomValidator CustomValidators::Uint256HexStringValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + return makeHexStringValidator(value, key); }}; CustomValidator CustomValidators::LedgerIndexValidator = diff --git a/src/rpc/common/Validators.hpp b/src/rpc/common/Validators.hpp index fd1ec4552..c0a039f43 100644 --- a/src/rpc/common/Validators.hpp +++ b/src/rpc/common/Validators.hpp @@ -458,6 +458,21 @@ class CustomValidator final { [[nodiscard]] bool checkIsU32Numeric(std::string_view sv); +template + requires(std::is_same_v || std::is_same_v || std::is_same_v) +MaybeError +makeHexStringValidator(boost::json::value const& value, std::string_view key) +{ + if (!value.is_string()) + return Error{Status{RippledError::rpcINVALID_PARAMS, std::string(key) + "NotString"}}; + + HexType parsedInt; + if (!parsedInt.parseHex(value.as_string().c_str())) + return Error{Status{RippledError::rpcINVALID_PARAMS, std::string(key) + "Malformed"}}; + + return MaybeError{}; +} + /** * @brief A group of custom validation functions */ @@ -492,6 +507,22 @@ struct CustomValidators final { */ static CustomValidator AccountMarkerValidator; + /** + * @brief Provides a commonly used validator for uint160(AccountID) hex string. + * + * It must be a string and also a decodable hex. + * AccountID uses this validator. + */ + static CustomValidator Uint160HexStringValidator; + + /** + * @brief Provides a commonly used validator for uint192 hex string. + * + * It must be a string and also a decodable hex. + * MPTIssuanceID uses this validator. + */ + static CustomValidator Uint192HexStringValidator; + /** * @brief Provides a commonly used validator for uint256 hex string. * diff --git a/src/rpc/common/impl/HandlerProvider.cpp b/src/rpc/common/impl/HandlerProvider.cpp index cd3b5edec..be5abdb3c 100644 --- a/src/rpc/common/impl/HandlerProvider.cpp +++ b/src/rpc/common/impl/HandlerProvider.cpp @@ -45,6 +45,7 @@ #include "rpc/handlers/LedgerEntry.hpp" #include "rpc/handlers/LedgerIndex.hpp" #include "rpc/handlers/LedgerRange.hpp" +#include "rpc/handlers/MPTHolders.hpp" #include "rpc/handlers/NFTBuyOffers.hpp" #include "rpc/handlers/NFTHistory.hpp" #include "rpc/handlers/NFTInfo.hpp" @@ -97,6 +98,7 @@ ProductionHandlerProvider::ProductionHandlerProvider( {"ledger_entry", {LedgerEntryHandler{backend}}}, {"ledger_index", {LedgerIndexHandler{backend}, true}}, // clio only {"ledger_range", {LedgerRangeHandler{backend}}}, + {"mpt_holders", {MPTHoldersHandler{backend}, true}}, // clio only {"nfts_by_issuer", {NFTsByIssuerHandler{backend}, true}}, // clio only {"nft_history", {NFTHistoryHandler{backend}, true}}, // clio only {"nft_buy_offers", {NFTBuyOffersHandler{backend}}}, diff --git a/src/rpc/handlers/LedgerEntry.cpp b/src/rpc/handlers/LedgerEntry.cpp index 9de270994..4f5ea1b1c 100644 --- a/src/rpc/handlers/LedgerEntry.cpp +++ b/src/rpc/handlers/LedgerEntry.cpp @@ -145,6 +145,16 @@ LedgerEntryHandler::process(LedgerEntryHandler::Input input, Context const& ctx) } } else if (input.oracleNode) { key = input.oracleNode.value(); + } else if (input.mptIssuance) { + auto const mptIssuanceID = ripple::uint192{std::string_view(*(input.mptIssuance))}; + key = ripple::keylet::mptIssuance(mptIssuanceID).key; + } else if (input.mptoken) { + auto const holder = + ripple::parseBase58(boost::json::value_to(input.mptoken->at(JS(account)))); + auto const mptIssuanceID = + ripple::uint192{std::string_view(boost::json::value_to(input.mptoken->at(JS(mpt_issuance_id)))) + }; + key = ripple::keylet::mptoken(mptIssuanceID, *holder).key; } else { // Must specify 1 of the following fields to indicate what type if (ctx.apiVersion == 1) @@ -277,6 +287,7 @@ tag_invoke(boost::json::value_to_tag, boost::json::va {JS(xchain_owned_create_account_claim_id), ripple::ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID}, {JS(xchain_owned_claim_id), ripple::ltXCHAIN_OWNED_CLAIM_ID}, {JS(oracle), ripple::ltORACLE}, + {JS(mptoken), ripple::ltMPTOKEN}, }; auto const parseBridgeFromJson = [](boost::json::value const& bridgeJson) { @@ -317,6 +328,8 @@ tag_invoke(boost::json::value_to_tag, boost::json::va input.accountRoot = boost::json::value_to(jv.at(JS(account_root))); } else if (jsonObject.contains(JS(did))) { input.did = boost::json::value_to(jv.at(JS(did))); + } else if (jsonObject.contains(JS(mpt_issuance))) { + input.mptIssuance = boost::json::value_to(jv.at(JS(mpt_issuance))); } // no need to check if_object again, validator only allows string or object else if (jsonObject.contains(JS(directory))) { @@ -348,6 +361,8 @@ tag_invoke(boost::json::value_to_tag, boost::json::va ); } else if (jsonObject.contains(JS(oracle))) { input.oracleNode = parseOracleFromJson(jv.at(JS(oracle))); + } else if (jsonObject.contains(JS(mptoken))) { + input.mptoken = jv.at(JS(mptoken)).as_object(); } if (jsonObject.contains("include_deleted")) diff --git a/src/rpc/handlers/LedgerEntry.hpp b/src/rpc/handlers/LedgerEntry.hpp index 50507d580..dfb7991c7 100644 --- a/src/rpc/handlers/LedgerEntry.hpp +++ b/src/rpc/handlers/LedgerEntry.hpp @@ -91,6 +91,8 @@ class LedgerEntryHandler { std::optional accountRoot; // account id to address did object std::optional did; + // mpt issuance id to address mptIssuance object + std::optional mptIssuance; // TODO: extract into custom objects, remove json from Input std::optional directory; std::optional offer; @@ -99,6 +101,7 @@ class LedgerEntryHandler { std::optional depositPreauth; std::optional ticket; std::optional amm; + std::optional mptoken; std::optional bridge; std::optional bridgeAccount; std::optional chainClaimId; @@ -315,6 +318,35 @@ class LedgerEntryHandler { }, meta::WithCustomError{modifiers::ToNumber{}, Status(ClioError::rpcMALFORMED_ORACLE_DOCUMENT_ID)}}, }}}, + {JS(mpt_issuance), + meta::WithCustomError{ + validation::CustomValidators::Uint192HexStringValidator, Status(ClioError::rpcMALFORMED_REQUEST) + }}, + {JS(mptoken), + meta::WithCustomError{ + validation::Type{}, Status(ClioError::rpcMALFORMED_REQUEST) + }, + meta::IfType{malformedRequestHexStringValidator}, + meta::IfType{ + meta::Section{ + { + JS(account), + meta::WithCustomError{validation::Required{}, Status(ClioError::rpcMALFORMED_REQUEST)}, + meta::WithCustomError{ + validation::CustomValidators::AccountBase58Validator, + Status(ClioError::rpcMALFORMED_ADDRESS) + }, + }, + { + JS(mpt_issuance_id), + meta::WithCustomError{validation::Required{}, Status(ClioError::rpcMALFORMED_REQUEST)}, + meta::WithCustomError{ + validation::CustomValidators::Uint192HexStringValidator, + Status(ClioError::rpcMALFORMED_REQUEST) + }, + }, + }, + }}, {JS(ledger), check::Deprecated{}}, {"include_deleted", validation::Type{}}, }; diff --git a/src/rpc/handlers/MPTHolders.cpp b/src/rpc/handlers/MPTHolders.cpp new file mode 100644 index 000000000..326859d05 --- /dev/null +++ b/src/rpc/handlers/MPTHolders.cpp @@ -0,0 +1,138 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, 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/MPTHolders.hpp" + +#include "rpc/Errors.hpp" +#include "rpc/JS.hpp" +#include "rpc/RPCHelpers.hpp" +#include "rpc/common/Types.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace ripple; + +namespace rpc { + +MPTHoldersHandler::Result +MPTHoldersHandler::process(MPTHoldersHandler::Input input, Context const& ctx) const +{ + auto const range = sharedPtrBackend_->fetchLedgerRange(); + auto const lgrInfoOrStatus = getLedgerHeaderFromHashOrSeq( + *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, range->maxSequence + ); + if (auto const status = std::get_if(&lgrInfoOrStatus)) + return Error{*status}; + + auto const lgrInfo = std::get(lgrInfoOrStatus); + auto const limit = input.limit.value_or(MPTHoldersHandler::LIMIT_DEFAULT); + auto const mptID = ripple::uint192{input.mptID.c_str()}; + + auto const issuanceLedgerObject = + sharedPtrBackend_->fetchLedgerObject(ripple::keylet::mptIssuance(mptID).key, lgrInfo.seq, ctx.yield); + if (!issuanceLedgerObject) + return Error{Status{RippledError::rpcOBJECT_NOT_FOUND, "objectNotFound"}}; + + std::optional cursor; + if (input.marker) + cursor = ripple::AccountID{input.marker->c_str()}; + + auto const dbResponse = sharedPtrBackend_->fetchMPTHolders(mptID, limit, cursor, lgrInfo.seq, ctx.yield); + auto output = MPTHoldersHandler::Output{}; + output.mptID = to_string(mptID); + output.limit = limit; + output.ledgerIndex = lgrInfo.seq; + + boost::json::array mpts; + for (auto const& mpt : dbResponse.mptokens) { + ripple::STLedgerEntry const sle{ripple::SerialIter{mpt.data(), mpt.size()}, keylet::mptIssuance(mptID).key}; + boost::json::object mptJson; + + mptJson[JS(account)] = toBase58(sle[ripple::sfAccount]); + mptJson[JS(flags)] = sle.getFlags(); + mptJson["mpt_amount"] = + toBoostJson(ripple::STUInt64{ripple::sfMPTAmount, sle[ripple::sfMPTAmount]}.getJson(JsonOptions::none)); + mptJson["mptoken_index"] = ripple::to_string(ripple::keylet::mptoken(mptID, sle[ripple::sfAccount]).key); + + output.mpts.push_back(mptJson); + } + + if (dbResponse.cursor.has_value()) + output.marker = strHex(*dbResponse.cursor); + + return output; +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, MPTHoldersHandler::Output const& output) +{ + jv = { + {JS(mpt_issuance_id), output.mptID}, + {JS(limit), output.limit}, + {JS(ledger_index), output.ledgerIndex}, + {"mptokens", output.mpts}, + {JS(validated), output.validated}, + }; + + if (output.marker.has_value()) + jv.as_object()[JS(marker)] = *(output.marker); +} + +MPTHoldersHandler::Input +tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) +{ + auto const& jsonObject = jv.as_object(); + MPTHoldersHandler::Input input; + + input.mptID = jsonObject.at(JS(mpt_issuance_id)).as_string().c_str(); + + if (jsonObject.contains(JS(ledger_hash))) + input.ledgerHash = jsonObject.at(JS(ledger_hash)).as_string().c_str(); + + if (jsonObject.contains(JS(ledger_index))) { + if (!jsonObject.at(JS(ledger_index)).is_string()) { + input.ledgerIndex = jsonObject.at(JS(ledger_index)).as_int64(); + } else if (jsonObject.at(JS(ledger_index)).as_string() != "validated") { + input.ledgerIndex = std::stoi(jsonObject.at(JS(ledger_index)).as_string().c_str()); + } + } + + if (jsonObject.contains(JS(limit))) + input.limit = jsonObject.at(JS(limit)).as_int64(); + + if (jsonObject.contains(JS(marker))) + input.marker = jsonObject.at(JS(marker)).as_string().c_str(); + + return input; +} +} // namespace rpc diff --git a/src/rpc/handlers/MPTHolders.hpp b/src/rpc/handlers/MPTHolders.hpp new file mode 100644 index 000000000..1bc55a342 --- /dev/null +++ b/src/rpc/handlers/MPTHolders.hpp @@ -0,0 +1,128 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, 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/JS.hpp" +#include "rpc/common/Modifiers.hpp" +#include "rpc/common/Specs.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/common/Validators.hpp" + +namespace rpc { + +/** + * @brief The mpt_holders command asks the Clio server for all holders of a particular MPTokenIssuance. + */ +class MPTHoldersHandler { + std::shared_ptr sharedPtrBackend_; + +public: + static auto constexpr LIMIT_MIN = 1; + static auto constexpr LIMIT_MAX = 100; + static auto constexpr LIMIT_DEFAULT = 50; + + /** + * @brief A struct to hold the output data of the command + */ + struct Output { + boost::json::array mpts; + uint32_t ledgerIndex; + std::string mptID; + bool validated = true; + uint32_t limit; + std::optional marker; + }; + + /** + * @brief A struct to hold the input data for the command + */ + struct Input { + std::string mptID; + std::optional ledgerHash; + std::optional ledgerIndex; + std::optional marker; + std::optional limit; + }; + + using Result = HandlerReturnType; + + /** + * @brief Construct a new MPTHoldersHandler object + * + * @param sharedPtrBackend The backend to use + */ + MPTHoldersHandler(std::shared_ptr const& sharedPtrBackend) : sharedPtrBackend_(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 apiVersion + */ + static RpcSpecConstRef + spec([[maybe_unused]] uint32_t apiVersion) + { + static auto const rpcSpec = RpcSpec{ + {JS(mpt_issuance_id), validation::Required{}, validation::CustomValidators::Uint192HexStringValidator}, + {JS(ledger_hash), validation::CustomValidators::Uint256HexStringValidator}, + {JS(ledger_index), validation::CustomValidators::LedgerIndexValidator}, + {JS(limit), + validation::Type{}, + validation::Min(1u), + modifiers::Clamp{LIMIT_MIN, LIMIT_MAX}}, + {JS(marker), validation::CustomValidators::Uint160HexStringValidator}, + }; + + return rpcSpec; + } + + /** + * @brief Process the MPTHolders 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 input, Context const& ctx) const; + +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); +}; +} // namespace rpc diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt index 8c28fdf95..abf142dbc 100644 --- a/src/util/CMakeLists.txt +++ b/src/util/CMakeLists.txt @@ -44,7 +44,7 @@ target_link_libraries( libbacktrace::libbacktrace fmt::fmt openssl::openssl - xrpl::libxrpl + xrpl-mpt::libxrpl Threads::Threads clio_options ) diff --git a/src/util/LedgerUtils.hpp b/src/util/LedgerUtils.hpp index 17f1096dc..dbc3afad9 100644 --- a/src/util/LedgerUtils.hpp +++ b/src/util/LedgerUtils.hpp @@ -113,6 +113,8 @@ class LedgerTypes { LedgerTypeAttribute::AccountOwnedLedgerType(JS(did), ripple::ltDID), LedgerTypeAttribute::AccountOwnedLedgerType(JS(oracle), ripple::ltORACLE), LedgerTypeAttribute::ChainLedgerType(JS(nunl), ripple::ltNEGATIVE_UNL), + LedgerTypeAttribute::DeletionBlockerLedgerType(JS(mpt_issuance), ripple::ltMPTOKEN_ISSUANCE), + LedgerTypeAttribute::DeletionBlockerLedgerType(JS(mptoken), ripple::ltMPTOKEN), }; public: diff --git a/tests/common/util/MockBackend.hpp b/tests/common/util/MockBackend.hpp index 71674acc2..d1b4674a1 100644 --- a/tests/common/util/MockBackend.hpp +++ b/tests/common/util/MockBackend.hpp @@ -205,4 +205,17 @@ struct MockBackend : public BackendInterface { MOCK_METHOD(void, doWriteLedgerObject, (std::string&&, std::uint32_t const, std::string&&), (override)); MOCK_METHOD(bool, doFinishWrites, (), (override)); + + MOCK_METHOD(void, writeMPTHolders, ((std::vector const&)), (override)); + + MOCK_METHOD( + MPTHoldersAndCursor, + fetchMPTHolders, + (ripple::uint192 const& mptID, + std::uint32_t const, + (std::optional const&), + std::uint32_t const, + boost::asio::yield_context), + (const, override) + ); }; diff --git a/tests/common/util/TestObject.cpp b/tests/common/util/TestObject.cpp index 5230241bc..cc12b2df1 100644 --- a/tests/common/util/TestObject.cpp +++ b/tests/common/util/TestObject.cpp @@ -1104,6 +1104,43 @@ CreateLPTCurrency(std::string_view assetCurrency, std::string_view asset2Currenc ); } +ripple::STObject +CreateMPTIssuanceObject(std::string_view accountId, std::uint32_t seq, std::string_view metadata) +{ + 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::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); + + return mptIssuance; +} + +ripple::STObject +CreateMPTokenObject(std::string_view accountId, ripple::uint192 issuanceID, std::uint64_t mptAmount) +{ + 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.setFieldU64(ripple::sfOwnerNode, 0); + mptoken.setFieldH256(ripple::sfPreviousTxnID, ripple::uint256{}); + mptoken.setFieldU32(ripple::sfPreviousTxnLgrSeq, 0); + + if (mptAmount) + mptoken.setFieldU64(ripple::sfMPTAmount, mptAmount); + + return mptoken; +} + ripple::STObject CreateOraclePriceData( uint64_t assetPrice, diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index 37b8417df..bf9bc107e 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -393,6 +393,12 @@ CreateDidObject(std::string_view accountId, std::string_view didDoc, std::string [[nodiscard]] ripple::Currency 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); + +[[nodiscard]] ripple::STObject +CreateMPTokenObject(std::string_view accountId, ripple::uint192 issuanceID, std::uint64_t mptAmount = 1); + [[nodiscard]] ripple::STObject CreateOraclePriceData( uint64_t assetPrice, diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 09b8fd703..e89096ae2 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -75,6 +75,7 @@ target_sources( rpc/handlers/LedgerIndexTests.cpp rpc/handlers/LedgerRangeTests.cpp rpc/handlers/LedgerTests.cpp + rpc/handlers/MPTHoldersTests.cpp rpc/handlers/NFTBuyOffersTests.cpp rpc/handlers/NFTHistoryTests.cpp rpc/handlers/NFTInfoTests.cpp diff --git a/tests/unit/rpc/BaseTests.cpp b/tests/unit/rpc/BaseTests.cpp index 398d8f771..5bd6e244e 100644 --- a/tests/unit/rpc/BaseTests.cpp +++ b/tests/unit/rpc/BaseTests.cpp @@ -515,6 +515,40 @@ TEST_F(RPCBaseTest, AccountMarkerValidator) ASSERT_TRUE(spec.process(passingInput)); } +TEST_F(RPCBaseTest, Uint160HexStringValidator) +{ + auto const spec = RpcSpec{{"marker", CustomValidators::Uint160HexStringValidator}}; + auto passingInput = json::parse(R"({ "marker": "F609A18102218C75767209946A77523CBD97E225"})"); + ASSERT_TRUE(spec.process(passingInput)); + + auto failingInput = json::parse(R"({ "marker": 160})"); + auto err = spec.process(failingInput); + ASSERT_FALSE(err); + ASSERT_EQ(err.error().message, "markerNotString"); + + failingInput = json::parse(R"({ "marker": "F609A18102218C75767209946A77523CBD97E2253515BC"})"); + err = spec.process(failingInput); + ASSERT_FALSE(err); + ASSERT_EQ(err.error().message, "markerMalformed"); +} + +TEST_F(RPCBaseTest, Uint192HexStringValidator) +{ + auto const spec = RpcSpec{{"mpt_issuance_id", CustomValidators::Uint192HexStringValidator}}; + auto passingInput = json::parse(R"({ "mpt_issuance_id": "0000012F27A9DE73EAA1E8831FA253E19030A17E2D038198"})"); + ASSERT_TRUE(spec.process(passingInput)); + + auto failingInput = json::parse(R"({ "mpt_issuance_id": 192})"); + auto err = spec.process(failingInput); + ASSERT_FALSE(err); + ASSERT_EQ(err.error().message, "mpt_issuance_idNotString"); + + failingInput = json::parse(R"({ "mpt_issuance_id": "0000012F27A9DE73EAA1E8831FA253E19030A17E2D038198983515BC"})"); + err = spec.process(failingInput); + ASSERT_FALSE(err); + ASSERT_EQ(err.error().message, "mpt_issuance_idMalformed"); +} + TEST_F(RPCBaseTest, Uint256HexStringValidator) { auto const spec = RpcSpec{{"transaction", CustomValidators::Uint256HexStringValidator}}; diff --git a/tests/unit/rpc/handlers/AccountObjectsTests.cpp b/tests/unit/rpc/handlers/AccountObjectsTests.cpp index 6fa7b5ab0..05f52455a 100644 --- a/tests/unit/rpc/handlers/AccountObjectsTests.cpp +++ b/tests/unit/rpc/handlers/AccountObjectsTests.cpp @@ -1626,3 +1626,95 @@ TEST_F(RPCAccountObjectsHandlerTest, LimitMoreThanMax) EXPECT_EQ(*output.result, json::parse(expectedOut)); }); } + +TEST_F(RPCAccountObjectsHandlerTest, TypeFilterMPTIssuanceType) +{ + backend->setRange(MINSEQ, MAXSEQ); + auto const ledgerinfo = CreateLedgerHeader(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerinfo)); + + auto const account = GetAccountIDWithString(ACCOUNT); + auto const accountKk = ripple::keylet::account(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(accountKk, MAXSEQ, _)).WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const ownerDir = CreateOwnerDirLedgerObject({ripple::uint256{INDEX1}}, INDEX1); + auto const ownerDirKk = ripple::keylet::ownerDir(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(ownerDirKk, 30, _)).WillOnce(Return(ownerDir.getSerializer().peekData())); + + // nft null + auto const nftMaxKK = ripple::keylet::nftpage_max(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(nftMaxKK, 30, _)).WillOnce(Return(std::nullopt)); + + std::vector bbs; + // put 1 mpt issuance + auto const issuanceObject = CreateMPTIssuanceObject(ACCOUNT, 2, "metadata"); + bbs.push_back(issuanceObject.getSerializer().peekData()); + + EXPECT_CALL(*backend, doFetchLedgerObjects).WillOnce(Return(bbs)); + + auto static const input = json::parse(fmt::format( + R"({{ + "account": "{}", + "type": "mpt_issuance" + }})", + ACCOUNT + )); + + auto const handler = AnyHandler{AccountObjectsHandler{backend}}; + runSpawn([&](auto yield) { + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + auto const& accountObjects = output.result->as_object().at("account_objects").as_array(); + ASSERT_EQ(accountObjects.size(), 1); + EXPECT_EQ(accountObjects.front().at("LedgerEntryType").as_string(), "MPTokenIssuance"); + + // make sure mptID is synethetically parsed if object is mptIssuance + EXPECT_EQ( + accountObjects.front().at("mpt_issuance_id").as_string(), + ripple::to_string(ripple::makeMptID(2, GetAccountIDWithString(ACCOUNT))) + ); + }); +} + +TEST_F(RPCAccountObjectsHandlerTest, TypeFilterMPTokenType) +{ + backend->setRange(MINSEQ, MAXSEQ); + auto const ledgerinfo = CreateLedgerHeader(LEDGERHASH, MAXSEQ); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerinfo)); + + auto const account = GetAccountIDWithString(ACCOUNT); + auto const accountKk = ripple::keylet::account(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(accountKk, MAXSEQ, _)).WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const ownerDir = CreateOwnerDirLedgerObject({ripple::uint256{INDEX1}}, INDEX1); + auto const ownerDirKk = ripple::keylet::ownerDir(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(ownerDirKk, 30, _)).WillOnce(Return(ownerDir.getSerializer().peekData())); + + // nft null + auto const nftMaxKK = ripple::keylet::nftpage_max(account).key; + EXPECT_CALL(*backend, doFetchLedgerObject(nftMaxKK, 30, _)).WillOnce(Return(std::nullopt)); + + std::vector bbs; + // put 1 mpt issuance + auto const mptokenObject = CreateMPTokenObject(ACCOUNT, ripple::makeMptID(2, GetAccountIDWithString(ACCOUNT))); + bbs.push_back(mptokenObject.getSerializer().peekData()); + + EXPECT_CALL(*backend, doFetchLedgerObjects).WillOnce(Return(bbs)); + + auto static const input = json::parse(fmt::format( + R"({{ + "account": "{}", + "type": "mptoken" + }})", + ACCOUNT + )); + + auto const handler = AnyHandler{AccountObjectsHandler{backend}}; + runSpawn([&](auto yield) { + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + auto const& accountObjects = output.result->as_object().at("account_objects").as_array(); + ASSERT_EQ(accountObjects.size(), 1); + EXPECT_EQ(accountObjects.front().at("LedgerEntryType").as_string(), "MPToken"); + }); +} diff --git a/tests/unit/rpc/handlers/LedgerDataTests.cpp b/tests/unit/rpc/handlers/LedgerDataTests.cpp index cc25770b4..1c7842b57 100644 --- a/tests/unit/rpc/handlers/LedgerDataTests.cpp +++ b/tests/unit/rpc/handlers/LedgerDataTests.cpp @@ -729,6 +729,88 @@ TEST_F(RPCLedgerDataHandlerTest, JsonLimitMoreThanMax) }); } +TEST_F(RPCLedgerDataHandlerTest, TypeFilterMPTIssuance) +{ + backend->setRange(RANGEMIN, RANGEMAX); + + EXPECT_CALL(*backend, fetchLedgerBySequence).Times(1); + ON_CALL(*backend, fetchLedgerBySequence(RANGEMAX, _)) + .WillByDefault(Return(CreateLedgerHeader(LEDGERHASH, RANGEMAX))); + + std::vector bbs; + EXPECT_CALL(*backend, doFetchSuccessorKey).Times(1); + ON_CALL(*backend, doFetchSuccessorKey(_, RANGEMAX, _)).WillByDefault(Return(ripple::uint256{INDEX2})); + + auto const issuance = CreateMPTIssuanceObject(ACCOUNT, 2, "metadata"); + bbs.push_back(issuance.getSerializer().peekData()); + + ON_CALL(*backend, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*backend, doFetchLedgerObjects).Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{LedgerDataHandler{backend}}; + auto const req = json::parse(R"({ + "limit":1, + "type":"mpt_issuance" + })"); + + auto output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_TRUE(output.result->as_object().contains("ledger")); + EXPECT_EQ(output.result->as_object().at("state").as_array().size(), 1); + EXPECT_EQ(output.result->as_object().at("marker").as_string(), INDEX2); + EXPECT_EQ(output.result->as_object().at("ledger_hash").as_string(), LEDGERHASH); + EXPECT_EQ(output.result->as_object().at("ledger_index").as_uint64(), RANGEMAX); + + auto const& objects = output.result->as_object().at("state").as_array(); + EXPECT_EQ(objects.front().at("LedgerEntryType").as_string(), "MPTokenIssuance"); + + // make sure mptID is synethetically parsed if object is mptIssuance + EXPECT_EQ( + objects.front().at("mpt_issuance_id").as_string(), + ripple::to_string(ripple::makeMptID(2, GetAccountIDWithString(ACCOUNT))) + ); + }); +} + +TEST_F(RPCLedgerDataHandlerTest, TypeFilterMPToken) +{ + backend->setRange(RANGEMIN, RANGEMAX); + + EXPECT_CALL(*backend, fetchLedgerBySequence).Times(1); + ON_CALL(*backend, fetchLedgerBySequence(RANGEMAX, _)) + .WillByDefault(Return(CreateLedgerHeader(LEDGERHASH, RANGEMAX))); + + std::vector bbs; + EXPECT_CALL(*backend, doFetchSuccessorKey).Times(1); + ON_CALL(*backend, doFetchSuccessorKey(_, RANGEMAX, _)).WillByDefault(Return(ripple::uint256{INDEX2})); + + auto const mptoken = CreateMPTokenObject(ACCOUNT, ripple::makeMptID(2, GetAccountIDWithString(ACCOUNT))); + bbs.push_back(mptoken.getSerializer().peekData()); + + ON_CALL(*backend, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*backend, doFetchLedgerObjects).Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{LedgerDataHandler{backend}}; + auto const req = json::parse(R"({ + "limit":1, + "type":"mptoken" + })"); + + auto output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_TRUE(output.result->as_object().contains("ledger")); + EXPECT_EQ(output.result->as_object().at("state").as_array().size(), 1); + EXPECT_EQ(output.result->as_object().at("marker").as_string(), INDEX2); + EXPECT_EQ(output.result->as_object().at("ledger_hash").as_string(), LEDGERHASH); + EXPECT_EQ(output.result->as_object().at("ledger_index").as_uint64(), RANGEMAX); + + auto const& objects = output.result->as_object().at("state").as_array(); + EXPECT_EQ(objects.front().at("LedgerEntryType").as_string(), "MPToken"); + }); +} + TEST(RPCLedgerDataHandlerSpecTest, DeprecatedFields) { boost::json::value const json{ diff --git a/tests/unit/rpc/handlers/LedgerEntryTests.cpp b/tests/unit/rpc/handlers/LedgerEntryTests.cpp index 51d9cd10f..26b7101f5 100644 --- a/tests/unit/rpc/handlers/LedgerEntryTests.cpp +++ b/tests/unit/rpc/handlers/LedgerEntryTests.cpp @@ -1759,6 +1759,76 @@ generateTestValuesForParametersTest() "malformedRequest", "Malformed request." }, + ParamTestCaseBundle{ + "InvalidMPTIssuanceStringIndex", + R"({ + "mpt_issuance": "invalid" + })", + "malformedRequest", + "Malformed request." + }, + ParamTestCaseBundle{ + "InvalidMPTIssuanceType", + R"({ + "mpt_issuance": 0 + })", + "malformedRequest", + "Malformed request." + }, + ParamTestCaseBundle{ + "InvalidMPTokenStringIndex", + R"({ + "mptoken": "invalid" + })", + "malformedRequest", + "Malformed request." + }, + ParamTestCaseBundle{ + "InvalidMPTokenObject", + fmt::format( + R"({{ + "mptoken": {{}} + }})" + ), + "malformedRequest", + "Malformed request." + }, + ParamTestCaseBundle{ + "MissingMPTokenID", + fmt::format( + R"({{ + "mptoken": {{ + "account": "{}" + }} + }})", + ACCOUNT + ), + "malformedRequest", + "Malformed request." + }, + ParamTestCaseBundle{ + "InvalidMPTokenAccount", + fmt::format( + R"({{ + "mptoken": {{ + "mpt_issuance_id": "0000019315EABA24E6135A4B5CE2899E0DA791206413B33D", + "account": 1 + }} + }})" + ), + "malformedAddress", + "Malformed address." + }, + ParamTestCaseBundle{ + "InvalidMPTokenType", + fmt::format( + R"({{ + "mptoken": 0 + }})" + ), + "malformedRequest", + "Malformed request." + }, }; } @@ -2397,6 +2467,46 @@ generateTestValuesForNormalPathTest() ) ) }, + NormalPathTestBundle{ + "MPTIssuance", + fmt::format( + R"({{ + "binary": true, + "mpt_issuance": "{}" + }})", + ripple::to_string(ripple::makeMptID(2, account1)) + ), + ripple::keylet::mptIssuance(ripple::makeMptID(2, account1)).key, + CreateMPTIssuanceObject(ACCOUNT, 2, "metadata") + }, + NormalPathTestBundle{ + "MPTokenViaIndex", + fmt::format( + R"({{ + "binary": true, + "mptoken": "{}" + }})", + INDEX1 + ), + ripple::uint256{INDEX1}, + CreateMPTokenObject(ACCOUNT, ripple::makeMptID(2, account1)) + }, + NormalPathTestBundle{ + "MPTokenViaObject", + fmt::format( + R"({{ + "binary": true, + "mptoken": {{ + "account": "{}", + "mpt_issuance_id": "{}" + }} + }})", + ACCOUNT, + ripple::to_string(ripple::makeMptID(2, account1)) + ), + ripple::keylet::mptoken(ripple::makeMptID(2, account1), account1).key, + CreateMPTokenObject(ACCOUNT, ripple::makeMptID(2, account1)) + }, }; } @@ -2944,6 +3054,56 @@ TEST_F(RPCLedgerEntryTest, ObjectSeqNotExist) }); } +// this testcase will test the if response includes synthetic mpt_issuance_id +TEST_F(RPCLedgerEntryTest, SyntheticMPTIssuanceID) +{ + static auto constexpr OUT = R"({ + "ledger_hash":"4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", + "ledger_index":30, + "validated":true, + "index":"FD7E7EFAE2A20E75850D0E0590B205E2F74DC472281768CD6E03988069816336", + "node":{ + "Flags":0, + "Issuer":"rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "LedgerEntryType":"MPTokenIssuance", + "MPTokenMetadata":"6D65746164617461", + "MaximumAmount":"0", + "OutstandingAmount":"0", + "OwnerNode":"0", + "PreviousTxnID":"0000000000000000000000000000000000000000000000000000000000000000", + "PreviousTxnLgrSeq":0, + "Sequence":2, + "index":"FD7E7EFAE2A20E75850D0E0590B205E2F74DC472281768CD6E03988069816336", + "mpt_issuance_id":"000000024B4E9C06F24296074F7BC48F92A97916C6DC5EA9" + } + })"; + + auto const mptId = ripple::makeMptID(2, GetAccountIDWithString(ACCOUNT)); + + backend->setRange(RANGEMIN, RANGEMAX); + // return valid ledgerHeader + auto const ledgerHeader = CreateLedgerHeader(LEDGERHASH, RANGEMAX); + EXPECT_CALL(*backend, fetchLedgerBySequence(RANGEMAX, _)).WillRepeatedly(Return(ledgerHeader)); + + // return valid ledger entry which can be deserialized + auto const ledgerEntry = CreateMPTIssuanceObject(ACCOUNT, 2, "metadata"); + EXPECT_CALL(*backend, doFetchLedgerObject(ripple::keylet::mptIssuance(mptId).key, RANGEMAX, _)) + .WillRepeatedly(Return(ledgerEntry.getSerializer().peekData())); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{LedgerEntryHandler{backend}}; + auto const req = json::parse(fmt::format( + R"({{ + "mpt_issuance": "{}" + }})", + ripple::to_string(mptId) + )); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(*output.result, json::parse(OUT)); + }); +} + using RPCLedgerEntryDeathTest = RPCLedgerEntryTest; TEST_F(RPCLedgerEntryDeathTest, RangeNotAvailable) diff --git a/tests/unit/rpc/handlers/MPTHoldersTests.cpp b/tests/unit/rpc/handlers/MPTHoldersTests.cpp new file mode 100644 index 000000000..984b71535 --- /dev/null +++ b/tests/unit/rpc/handlers/MPTHoldersTests.cpp @@ -0,0 +1,651 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, 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/MPTHolders.hpp" +#include "util/HandlerBaseTestFixture.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace rpc; +namespace json = boost::json; +using namespace testing; + +// constexpr static auto ISSUER_ACCOUNT = "rsS8ju2jYabSKJ6uzLarAS1gEzvRQ6JAiF"; +constexpr static auto HOLDER1_ACCOUNT = "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN"; +constexpr static auto HOLDER2_ACCOUNT = "rEiNkzogdHEzUxPfsri5XSMqtXUixf2Yx"; +constexpr static auto LEDGERHASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr static auto MPTID = "000004C463C52827307480341125DA0577DEFC38405B0E3E"; + +static std::string MPTOUT1 = + R"({ + "account": "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN", + "flags": 0, + "mpt_amount": "1", + "mptoken_index": "D137F2E5A5767A06CB7A8F060ADE442A30CFF95028E1AF4B8767E3A56877205A" + })"; + +static std::string MPTOUT2 = + R"({ + "account": "rEiNkzogdHEzUxPfsri5XSMqtXUixf2Yx", + "flags": 0, + "mpt_amount": "1", + "mptoken_index": "36D91DEE5EFE4A93119A8B84C944A528F2B444329F3846E49FE921040DE17E65" + })"; + +class RPCMPTHoldersHandlerTest : public HandlerBaseTest {}; + +TEST_F(RPCMPTHoldersHandlerTest, NonHexLedgerHash) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_hash": "xxx" + }})", + MPTID + )); + auto const output = handler.process(input, Context{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(), "ledger_hashMalformed"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, NonStringLedgerHash) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_hash": 123 + }})", + MPTID + )); + auto const output = handler.process(input, Context{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(), "ledger_hashNotString"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, InvalidLedgerIndexString) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_index": "notvalidated" + }})", + MPTID + )); + auto const output = handler.process(input, Context{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(), "ledgerIndexMalformed"); + }); +} + +// error case: issuer invalid format, length is incorrect +TEST_F(RPCMPTHoldersHandlerTest, MPTIDInvalidFormat) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(R"({ + "mpt_issuance_id": "xxx" + })"); + auto const output = handler.process(input, Context{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(), "mpt_issuance_idMalformed"); + }); +} + +// error case: issuer missing +TEST_F(RPCMPTHoldersHandlerTest, MPTIDMissing) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(R"({})"); + auto const output = handler.process(input, Context{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(), "Required field 'mpt_issuance_id' missing"); + }); +} + +// error case: issuer invalid format +TEST_F(RPCMPTHoldersHandlerTest, MPTIDNotString) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(R"({ + "mpt_issuance_id": 12 + })"); + auto const output = handler.process(input, Context{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(), "mpt_issuance_idNotString"); + }); +} + +// error case: invalid marker format +TEST_F(RPCMPTHoldersHandlerTest, MarkerInvalidFormat) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "marker": "xxx" + }})", + MPTID + )); + auto const output = handler.process(input, Context{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(), "markerMalformed"); + }); +} + +// error case: invalid marker type +TEST_F(RPCMPTHoldersHandlerTest, MarkerNotString) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "marker": 1 + }})", + MPTID + )); + auto const output = handler.process(input, Context{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(), "markerNotString"); + }); +} + +// error case ledger non exist via hash +TEST_F(RPCMPTHoldersHandlerTest, NonExistLedgerViaLedgerHash) +{ + // mock fetchLedgerByHash return empty + EXPECT_CALL(*backend, fetchLedgerByHash).Times(1); + ON_CALL(*backend, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)) + .WillByDefault(Return(std::optional{})); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}" + }})", + MPTID, + LEDGERHASH + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const output = handler.process(input, Context{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"); + }); +} + +// error case ledger non exist via index +TEST_F(RPCMPTHoldersHandlerTest, NonExistLedgerViaLedgerStringIndex) +{ + backend->setRange(10, 30); + // mock fetchLedgerBySequence return empty + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_index": "4" + }})", + MPTID + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const output = handler.process(input, Context{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"); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, NonExistLedgerViaLedgerIntIndex) +{ + backend->setRange(10, 30); + // mock fetchLedgerBySequence return empty + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(std::optional{})); + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_index": 4 + }})", + MPTID + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const output = handler.process(input, Context{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"); + }); +} + +// error case ledger > max seq via hash +// idk why this case will happen in reality +TEST_F(RPCMPTHoldersHandlerTest, NonExistLedgerViaLedgerHash2) +{ + backend->setRange(10, 30); + // mock fetchLedgerByHash return ledger but seq is 31 > 30 + auto ledgerinfo = CreateLedgerHeader(LEDGERHASH, 31); + ON_CALL(*backend, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)).WillByDefault(Return(ledgerinfo)); + EXPECT_CALL(*backend, fetchLedgerByHash).Times(1); + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}" + }})", + MPTID, + LEDGERHASH + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const output = handler.process(input, Context{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"); + }); +} + +// error case ledger > max seq via index +TEST_F(RPCMPTHoldersHandlerTest, NonExistLedgerViaLedgerIndex2) +{ + backend->setRange(10, 30); + // no need to check from db,call fetchLedgerBySequence 0 time + // differ from previous logic + EXPECT_CALL(*backend, fetchLedgerBySequence).Times(0); + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_index": "31" + }})", + MPTID + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{MPTHoldersHandler{backend}}; + auto const output = handler.process(input, Context{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"); + }); +} + +// normal case when MPT does not exist +TEST_F(RPCMPTHoldersHandlerTest, MPTNotFound) +{ + backend->setRange(10, 30); + auto ledgerinfo = CreateLedgerHeader(LEDGERHASH, 30); + ON_CALL(*backend, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)).WillByDefault(Return(ledgerinfo)); + EXPECT_CALL(*backend, fetchLedgerByHash).Times(1); + ON_CALL(*backend, doFetchLedgerObject).WillByDefault(Return(std::optional{})); + EXPECT_CALL(*backend, doFetchLedgerObject).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}" + }})", + MPTID, + LEDGERHASH + )); + runSpawn([&, this](boost::asio::yield_context yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->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(), "objectNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "objectNotFound"); + }); +} + +// normal case when mpt has one holder +TEST_F(RPCMPTHoldersHandlerTest, DefaultParameters) +{ + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":50, + "ledger_index": 30, + "mptokens": [{}], + "validated": true + }})", + MPTID, + MPTOUT1 + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, 30); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, 30, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = CreateMPTokenObject(HOLDER1_ACCOUNT, ripple::uint192(MPTID)); + std::vector const mpts = {mptoken.getSerializer().peekData()}; + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, {}})); + EXPECT_CALL( + *backend, fetchMPTHolders(ripple::uint192(MPTID), testing::_, testing::Eq(std::nullopt), Const(30), testing::_) + ) + .Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}" + }})", + MPTID + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, CustomAmounts) +{ + // it's not possible to have locked_amount to be greater than mpt_amount, + // we are simply testing the response parsing of the api + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":50, + "ledger_index": 30, + "mptokens": [{{ + "account": "rrnAZCqMahreZrKMcZU3t2DZ6yUndT4ubN", + "flags": 0, + "mpt_amount": "0", + "mptoken_index": "D137F2E5A5767A06CB7A8F060ADE442A30CFF95028E1AF4B8767E3A56877205A" + }}], + "validated": true + }})", + MPTID + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, 30); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, 30, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = CreateMPTokenObject(HOLDER1_ACCOUNT, ripple::uint192(MPTID), 0); + std::vector const mpts = {mptoken.getSerializer().peekData()}; + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, {}})); + EXPECT_CALL( + *backend, fetchMPTHolders(ripple::uint192(MPTID), testing::_, testing::Eq(std::nullopt), Const(30), testing::_) + ) + .Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}" + }})", + MPTID + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, SpecificLedgerIndex) +{ + auto const specificLedger = 20; + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":50, + "ledger_index": {}, + "mptokens": [{}], + "validated": true + }})", + MPTID, + specificLedger, + MPTOUT1 + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, specificLedger); + ON_CALL(*backend, fetchLedgerBySequence(specificLedger, _)).WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*backend, fetchLedgerBySequence).Times(1); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, specificLedger, _)) + .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = CreateMPTokenObject(HOLDER1_ACCOUNT, ripple::uint192(MPTID)); + std::vector const mpts = {mptoken.getSerializer().peekData()}; + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, {}})); + EXPECT_CALL( + *backend, + fetchMPTHolders( + ripple::uint192(MPTID), testing::_, testing::Eq(std::nullopt), Const(specificLedger), testing::_ + ) + ) + .Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "ledger_index": {} + }})", + MPTID, + specificLedger + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, MarkerParameter) +{ + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":50, + "ledger_index": 30, + "mptokens": [{}], + "validated": true, + "marker": "{}" + }})", + MPTID, + MPTOUT2, + ripple::strHex(GetAccountIDWithString(HOLDER1_ACCOUNT)) + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, 30); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, 30, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = CreateMPTokenObject(HOLDER2_ACCOUNT, ripple::uint192(MPTID)); + std::vector const mpts = {mptoken.getSerializer().peekData()}; + auto const marker = GetAccountIDWithString(HOLDER1_ACCOUNT); + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, marker})); + EXPECT_CALL( + *backend, fetchMPTHolders(ripple::uint192(MPTID), testing::_, testing::Eq(marker), Const(30), testing::_) + ) + .Times(1); + + auto const HOLDER1_ACCOUNTID = ripple::strHex(GetAccountIDWithString(HOLDER1_ACCOUNT)); + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "marker": "{}" + }})", + MPTID, + HOLDER1_ACCOUNTID + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, MultipleMPTs) +{ + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":50, + "ledger_index": 30, + "mptokens": [{}, {}], + "validated": true + }})", + MPTID, + MPTOUT1, + MPTOUT2 + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, 30); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, 30, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken1 = CreateMPTokenObject(HOLDER1_ACCOUNT, ripple::uint192(MPTID)); + auto const mptoken2 = CreateMPTokenObject(HOLDER2_ACCOUNT, ripple::uint192(MPTID)); + std::vector const mpts = {mptoken1.getSerializer().peekData(), mptoken2.getSerializer().peekData()}; + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, {}})); + EXPECT_CALL( + *backend, fetchMPTHolders(ripple::uint192(MPTID), testing::_, testing::Eq(std::nullopt), Const(30), testing::_) + ) + .Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}" + }})", + MPTID + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} + +TEST_F(RPCMPTHoldersHandlerTest, LimitMoreThanMAx) +{ + auto const currentOutput = fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit":100, + "ledger_index": 30, + "mptokens": [{}], + "validated": true + }})", + MPTID, + MPTOUT1 + ); + + backend->setRange(10, 30); + auto ledgerInfo = CreateLedgerHeader(LEDGERHASH, 30); + EXPECT_CALL(*backend, fetchLedgerBySequence).WillOnce(Return(ledgerInfo)); + auto const issuanceKk = ripple::keylet::mptIssuance(ripple::uint192(MPTID)).key; + ON_CALL(*backend, doFetchLedgerObject(issuanceKk, 30, _)).WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); + + auto const mptoken = CreateMPTokenObject(HOLDER1_ACCOUNT, ripple::uint192(MPTID)); + std::vector const mpts = {mptoken.getSerializer().peekData()}; + ON_CALL(*backend, fetchMPTHolders).WillByDefault(Return(MPTHoldersAndCursor{mpts, {}})); + EXPECT_CALL( + *backend, + fetchMPTHolders( + ripple::uint192(MPTID), + Const(MPTHoldersHandler::LIMIT_MAX), + testing::Eq(std::nullopt), + Const(30), + testing::_ + ) + ) + .Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "mpt_issuance_id": "{}", + "limit": {} + }})", + MPTID, + MPTHoldersHandler::LIMIT_MAX + 1 + )); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{MPTHoldersHandler{this->backend}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(currentOutput), *output.result); + }); +} diff --git a/tests/unit/util/LedgerUtilsTests.cpp b/tests/unit/util/LedgerUtilsTests.cpp index eff2154b5..60ac92260 100644 --- a/tests/unit/util/LedgerUtilsTests.cpp +++ b/tests/unit/util/LedgerUtilsTests.cpp @@ -52,6 +52,8 @@ TEST(LedgerUtilsTests, LedgerObjectTypeList) JS(xchain_owned_claim_id), JS(xchain_owned_create_account_claim_id), JS(did), + JS(mpt_issuance), + JS(mptoken), JS(oracle), JS(nunl) }; @@ -83,7 +85,9 @@ TEST(LedgerUtilsTests, AccountOwnedTypeList) JS(xchain_owned_claim_id), JS(xchain_owned_create_account_claim_id), JS(did), - JS(oracle) + JS(oracle), + JS(mpt_issuance), + JS(mptoken) }; static_assert(std::size(correctTypes) == accountOwned.size()); @@ -121,7 +125,9 @@ TEST(LedgerUtilsTests, DeletionBlockerTypes) ripple::ltRIPPLE_STATE, ripple::ltXCHAIN_OWNED_CLAIM_ID, ripple::ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID, - ripple::ltBRIDGE + ripple::ltBRIDGE, + ripple::ltMPTOKEN_ISSUANCE, + ripple::ltMPTOKEN }; static_assert(std::size(deletionBlockers) == testedTypes.size());