From e8e0478333c86ccc0e1d526f0cd2a2f19b45479c Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 18 Aug 2026 09:43:06 -0700 Subject: [PATCH 1/4] feat: Add mptoken_issuance_history RPC (#3141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add mptoken_issuance_history RPC Summary Adds mptoken_issuance_history, a Clio-only method that returns the transaction history for a given MPT issuance — the MPT-scoped sibling of nft_history. You can optionally filter by account and/or tx_type. The index tables, backend fetch methods, and live ETL indexing landed earlier; this wires up the handler on top of them. The handler - Routes to fetchMPTokenIssuanceTransactions or fetchAccountMPTokenIssuanceTransactions depending on whether account is set. When tx_type is given, it filters on TransactionType post-fetch, exactly like account_tx. - Follows the nft_history/account_tx conventions for ledger ranges, markers, binary, forward, and limit ([1,100], default 50), including api-version response branching. - Gated on backfill completion so it never returns partial history: until this node's MPTTransactionHistoryMigrator reports Migrated, requests get notReady with a message pointing at the --migrate command. Once migrated, the result is cached and the check is skipped — the method turns on automatically, no restart. --------- Co-authored-by: Claude Opus 5 (1M context) --- src/rpc/CMakeLists.txt | 1 + src/rpc/RPCCenter.cpp | 1 + src/rpc/common/impl/HandlerProvider.cpp | 3 + src/rpc/handlers/MPTokenIssuanceHistory.cpp | 378 +++ src/rpc/handlers/MPTokenIssuanceHistory.hpp | 316 +++ tests/unit/CMakeLists.txt | 1 + .../handlers/MPTokenIssuanceHistoryTests.cpp | 2176 +++++++++++++++++ 7 files changed, 2876 insertions(+) create mode 100644 src/rpc/handlers/MPTokenIssuanceHistory.cpp create mode 100644 src/rpc/handlers/MPTokenIssuanceHistory.hpp create mode 100644 tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 9691de44d..e9ca6dc81 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -44,6 +44,7 @@ target_sources( handlers/LedgerIndex.cpp handlers/LedgerRange.cpp handlers/MPTHolders.cpp + handlers/MPTokenIssuanceHistory.cpp handlers/NFTsByIssuer.cpp handlers/NFTBuyOffers.cpp handlers/NFTHistory.cpp diff --git a/src/rpc/RPCCenter.cpp b/src/rpc/RPCCenter.cpp index 8280ded91..22fa9d8b9 100644 --- a/src/rpc/RPCCenter.cpp +++ b/src/rpc/RPCCenter.cpp @@ -35,6 +35,7 @@ handledRpcs() "ledger_index", "ledger_range", "mpt_holders", + "mptoken_issuance_history", "nfts_by_issuer", "nft_history", "nft_buy_offers", diff --git a/src/rpc/common/impl/HandlerProvider.cpp b/src/rpc/common/impl/HandlerProvider.cpp index dae6121f2..c199753c8 100644 --- a/src/rpc/common/impl/HandlerProvider.cpp +++ b/src/rpc/common/impl/HandlerProvider.cpp @@ -30,6 +30,7 @@ #include "rpc/handlers/LedgerIndex.hpp" #include "rpc/handlers/LedgerRange.hpp" #include "rpc/handlers/MPTHolders.hpp" +#include "rpc/handlers/MPTokenIssuanceHistory.hpp" #include "rpc/handlers/NFTBuyOffers.hpp" #include "rpc/handlers/NFTHistory.hpp" #include "rpc/handlers/NFTInfo.hpp" @@ -91,6 +92,8 @@ ProductionHandlerProvider::ProductionHandlerProvider( {"ledger_range", {.handler = LedgerRangeHandler{backend}}}, {"mpt_holders", {.handler = MPTHoldersHandler{backend}, .isClioOnly = true}}, // clio only + {"mptoken_issuance_history", + {.handler = MPTokenIssuanceHistoryHandler{backend}, .isClioOnly = true}}, // clio only {"nfts_by_issuer", {.handler = NFTsByIssuerHandler{backend}, .isClioOnly = true}}, // clio only {"nft_history", diff --git a/src/rpc/handlers/MPTokenIssuanceHistory.cpp b/src/rpc/handlers/MPTokenIssuanceHistory.cpp new file mode 100644 index 000000000..813528ed3 --- /dev/null +++ b/src/rpc/handlers/MPTokenIssuanceHistory.cpp @@ -0,0 +1,378 @@ +#include "rpc/handlers/MPTokenIssuanceHistory.hpp" + +#include "data/Types.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 "util/Profiler.hpp" +#include "util/log/Logger.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace rpc { + +namespace { + +/** + * @brief The migrator status that reports a completed backfill. + * + * This is a literal to keep migration headers out of RPC; it must match the string form of + * `migration::MigratorStatus::Status::Migrated`. + */ +constexpr auto kMigratedStatus = "Migrated"; + +} // namespace + +MPTokenIssuanceHistoryHandler::Result +MPTokenIssuanceHistoryHandler::process( + MPTokenIssuanceHistoryHandler::Input const& input, + Context const& ctx +) const +{ + if (auto const available = verifyHistoryAvailable(ctx); not available.has_value()) + return Error{available.error()}; + + auto const range = resolveSequenceRange(input, ctx); + if (not range.has_value()) + return Error{range.error()}; + + auto const mptIssuanceID = xrpl::uint192{input.mptIssuanceID.c_str()}; + + auto const [page, timeDiff] = + util::timed([&] { return fetchTransactions(input, ctx, mptIssuanceID, *range); }); + LOG(log_.info()) << "db fetch took " << timeDiff + << " milliseconds - num blobs = " << page.txns.size(); + + Output response; + response.mptIssuanceID = xrpl::to_string(mptIssuanceID); + response.ledgerIndexMin = range->min; + response.ledgerIndexMax = range->max; + response.limit = input.limit; + + processTransactionsPage(input, ctx, *range, page, response); + + return response; +} + +MaybeError +MPTokenIssuanceHistoryHandler::verifyHistoryAvailable(Context const& ctx) const +{ + if (migrated_->load(std::memory_order_relaxed)) + return {}; + + auto const statusString = sharedPtrBackend_->fetchMigratorStatus(kMigratorName, ctx.yield); + if (statusString.has_value() and *statusString == kMigratedStatus) { + migrated_->store(true, std::memory_order_relaxed); + return {}; + } + + // Fail closed: partial history must never be served. + return Error{Status{ + RippledError::RpcNotReady, + "mptoken_issuance_history is not available on this server because the required " + "transaction-history backfill has not completed." + }}; +} + +std::expected +MPTokenIssuanceHistoryHandler::resolveSequenceRange(Input const& input, Context const& ctx) const +{ + auto const ledgerRange = sharedPtrBackend_->fetchLedgerRange(); + ASSERT(ledgerRange.has_value(), "MPTokenIssuanceHistory's ledger range must be available"); + + auto const [dbMinSeq, dbMaxSeq] = *ledgerRange; // NOLINT(bugprone-unchecked-optional-access) + auto resolved = SequenceRange{.min = dbMinSeq, .max = dbMaxSeq}; + + if (input.ledgerIndexMin.has_value()) { + if (dbMaxSeq < input.ledgerIndexMin || dbMinSeq > input.ledgerIndexMin) + return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMinOutOfRange"}}; + + resolved.min = *input.ledgerIndexMin; + } + + if (input.ledgerIndexMax.has_value()) { + if (dbMaxSeq < input.ledgerIndexMax || dbMinSeq > input.ledgerIndexMax) + return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMaxOutOfRange"}}; + + resolved.max = *input.ledgerIndexMax; + } + + if (resolved.min > resolved.max) + return Error{Status{RippledError::RpcLgrIdxsInvalid}}; + + if (input.ledgerHash.has_value() || input.ledgerIndex.has_value()) { + // rippled does not have this check + if (input.ledgerIndexMax.has_value() || input.ledgerIndexMin.has_value()) + return Error{Status{RippledError::RpcInvalidParams, "containsLedgerSpecifierAndRange"}}; + + auto const expectedLgrInfo = getLedgerHeaderFromHashOrSeq( + *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, dbMaxSeq + ); + + if (not expectedLgrInfo.has_value()) + return Error{expectedLgrInfo.error()}; + + resolved.max = resolved.min = expectedLgrInfo->seq; + } + + return resolved; +} + +data::TransactionsAndCursor +MPTokenIssuanceHistoryHandler::fetchTransactions( + Input const& input, + Context const& ctx, + xrpl::uint192 const& mptIssuanceID, + SequenceRange range +) const +{ + // Construct the database cursor as {ledgerSequence, transactionIndex}. + auto const startCursor = [&]() -> data::TransactionsCursor { + if (input.marker.has_value()) + return {input.marker->ledger, input.marker->seq}; + + // Forward iteration starts at the first possible transaction in the lowest ledger. + if (input.forward) + return {range.min, 0}; + + // Reverse iteration starts after all possible transactions in the highest ledger. + return {range.max, std::numeric_limits::max()}; + }(); + + auto const limit = input.limit.value_or(kLimitDefault); + + // tx_type is applied post-fetch, as account_tx does. + if (input.account.has_value()) { + auto const account = accountFromStringStrict(*input.account); + if (not account.has_value()) { + ASSERT(false, "Account must be decodable after spec validation"); + std::unreachable(); + } + return sharedPtrBackend_->fetchAccountMPTokenIssuanceTransactions( + mptIssuanceID, *account, limit, input.forward, startCursor, ctx.yield + ); + } + + return sharedPtrBackend_->fetchMPTokenIssuanceTransactions( + mptIssuanceID, limit, input.forward, startCursor, ctx.yield + ); +} + +void +MPTokenIssuanceHistoryHandler::processTransactionsPage( + Input const& input, + Context const& ctx, + SequenceRange range, + data::TransactionsAndCursor const& page, + Output& response +) const +{ + if (page.cursor.has_value()) { + response.marker = { + .ledger = page.cursor->ledgerSequence, .seq = page.cursor->transactionIndex + }; + } + + for (auto const& txnPlusMeta : page.txns) { + // A hash with no matching Transactions row yields a default-constructed record in-position. + // Skip it before the range check so it neither shortens the page nor disturbs the marker. + if (txnPlusMeta.transaction.empty() || txnPlusMeta.metadata.empty()) { + LOG(log_.warn()) << "Skipping index entry with no matching transaction record; " + "mpt_issuance_id = " + << input.mptIssuanceID; + continue; + } + + // Stop once iteration passes the far edge of the requested range. + if ((txnPlusMeta.ledgerSequence < range.min && !input.forward) || + (txnPlusMeta.ledgerSequence > range.max && input.forward)) { + response.marker = std::nullopt; + break; + } + if (txnPlusMeta.ledgerSequence > range.max && !input.forward) { + LOG(log_.debug()) << "Skipping over transactions from incomplete ledger"; + continue; + } + + if (auto obj = transactionToJsonIfTypeMatches(txnPlusMeta, input, ctx); obj.has_value()) + response.transactions.push_back(std::move(*obj)); + } +} + +std::optional +MPTokenIssuanceHistoryHandler::transactionToJsonIfTypeMatches( + data::TransactionAndMetadata const& txnPlusMeta, + Input const& input, + Context const& ctx +) const +{ + // The type filter needs the expanded form to read TransactionType, even for binary output. + if (!input.binary || input.transactionTypeInLowercase.has_value()) { + auto [txn, meta] = toExpandedJson(txnPlusMeta, ctx.apiVersion); + + if (txn.contains(JS(TransactionType)) && input.transactionTypeInLowercase.has_value() && + util::toLower(boost::json::value_to(txn[JS(TransactionType)])) != + *input.transactionTypeInLowercase) + return std::nullopt; + + if (!input.binary) + return expandedTransactionToJson(std::move(txn), std::move(meta), txnPlusMeta, ctx); + } + + auto obj = toJsonWithBinaryTx(txnPlusMeta, ctx.apiVersion); + obj[JS(ledger_index)] = txnPlusMeta.ledgerSequence; + obj[JS(date)] = txnPlusMeta.date; + obj[JS(validated)] = true; + + return obj; +} + +boost::json::object +MPTokenIssuanceHistoryHandler::expandedTransactionToJson( + boost::json::object txn, + boost::json::object meta, + data::TransactionAndMetadata const& txnPlusMeta, + Context const& ctx +) const +{ + auto const txKey = ctx.apiVersion > 1u ? JS(tx_json) : JS(tx); + + boost::json::object obj; + obj[JS(meta)] = std::move(meta); + obj[txKey] = std::move(txn); + obj[txKey].as_object()[JS(ledger_index)] = txnPlusMeta.ledgerSequence; + obj[txKey].as_object()[JS(date)] = txnPlusMeta.date; + + if (ctx.apiVersion > 1u) { + obj[JS(ledger_index)] = txnPlusMeta.ledgerSequence; + if (obj[txKey].as_object().contains(JS(hash))) { + obj[JS(hash)] = obj[txKey].at(JS(hash)); + obj[txKey].as_object().erase(JS(hash)); + } + if (auto const lgrInfo = + sharedPtrBackend_->fetchLedgerBySequence(txnPlusMeta.ledgerSequence, ctx.yield); + lgrInfo.has_value()) { + obj[JS(close_time_iso)] = xrpl::toStringIso(lgrInfo->closeTime); + obj[JS(ledger_hash)] = xrpl::strHex(lgrInfo->hash); + } + } + + obj[JS(validated)] = true; + + return obj; +} + +void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + MPTokenIssuanceHistoryHandler::Output const& output +) +{ + jv = { + {JS(mpt_issuance_id), output.mptIssuanceID}, + {JS(ledger_index_min), output.ledgerIndexMin}, + {JS(ledger_index_max), output.ledgerIndexMax}, + {JS(transactions), output.transactions}, + {JS(validated), output.validated}, + }; + + if (output.marker.has_value()) + jv.as_object()[JS(marker)] = boost::json::value_from(*(output.marker)); + + if (output.limit.has_value()) + jv.as_object()[JS(limit)] = *(output.limit); +} + +void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + MPTokenIssuanceHistoryHandler::Marker const& marker +) +{ + jv = { + {JS(ledger), marker.ledger}, + {JS(seq), marker.seq}, + }; +} + +MPTokenIssuanceHistoryHandler::Input +tag_invoke( + boost::json::value_to_tag, + boost::json::value const& jv +) +{ + auto const& jsonObject = jv.as_object(); + auto input = MPTokenIssuanceHistoryHandler::Input{}; + + input.mptIssuanceID = boost::json::value_to(jsonObject.at(JS(mpt_issuance_id))); + + if (jsonObject.contains(JS(account))) + input.account = boost::json::value_to(jsonObject.at(JS(account))); + + if (jsonObject.contains("tx_type")) { + input.transactionTypeInLowercase = + boost::json::value_to(jsonObject.at("tx_type")); + } + + if (jsonObject.contains(JS(ledger_index_min)) && + util::integralValueAs(jsonObject.at(JS(ledger_index_min))) != -1) + input.ledgerIndexMin = util::integralValueAs(jsonObject.at(JS(ledger_index_min))); + + if (jsonObject.contains(JS(ledger_index_max)) && + util::integralValueAs(jsonObject.at(JS(ledger_index_max))) != -1) + input.ledgerIndexMax = util::integralValueAs(jsonObject.at(JS(ledger_index_max))); + + if (jsonObject.contains(JS(ledger_hash))) + input.ledgerHash = boost::json::value_to(jsonObject.at(JS(ledger_hash))); + + if (jsonObject.contains(JS(ledger_index))) { + auto const expectedLedgerIndex = util::getLedgerIndex(jsonObject.at(JS(ledger_index))); + if (expectedLedgerIndex.has_value()) + input.ledgerIndex = *expectedLedgerIndex; + } + + if (jsonObject.contains(JS(binary))) + input.binary = jsonObject.at(JS(binary)).as_bool(); + + if (jsonObject.contains(JS(forward))) + input.forward = jsonObject.at(JS(forward)).as_bool(); + + if (jsonObject.contains(JS(limit))) + input.limit = util::integralValueAs(jsonObject.at(JS(limit))); + + if (jsonObject.contains(JS(marker))) { + input.marker = MPTokenIssuanceHistoryHandler::Marker{ + .ledger = util::integralValueAs( + jsonObject.at(JS(marker)).as_object().at(JS(ledger)) + ), + .seq = + util::integralValueAs(jsonObject.at(JS(marker)).as_object().at(JS(seq))) + }; + } + + return input; +} + +} // namespace rpc diff --git a/src/rpc/handlers/MPTokenIssuanceHistory.hpp b/src/rpc/handlers/MPTokenIssuanceHistory.hpp new file mode 100644 index 000000000..b5f565bff --- /dev/null +++ b/src/rpc/handlers/MPTokenIssuanceHistory.hpp @@ -0,0 +1,316 @@ +#pragma once + +#include "data/BackendInterface.hpp" +#include "data/Types.hpp" +#include "rpc/Errors.hpp" +#include "rpc/JS.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 "util/TxUtils.hpp" +#include "util/log/Logger.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace rpc { + +/** + * @brief The mptoken_issuance_history command returns past transactions associated with the queried + * MPTokenIssuance, optionally filtered by an affected account and/or transaction type. + * + * @note This is a Clio-only method. Requests fail with `notReady` until the issuance-history + * backfill reports `Migrated`, so partial history is never served. + */ +class MPTokenIssuanceHistoryHandler { + util::Logger log_{"RPC"}; + std::shared_ptr sharedPtrBackend_; + + /** + * @brief Whether the issuance-history backfill has completed. + * + * The status is monotonic, so the terminal result is cached across handler copies. + */ + std::shared_ptr migrated_ = std::make_shared(false); + +public: + static constexpr auto kLimitMin = 1; + static constexpr auto kLimitMax = 100; + static constexpr auto kLimitDefault = 50; + + /** + * @brief The name used to query the issuance-history migrator's status. + * + * This is a literal to keep Cassandra migration headers out of RPC. + */ + static constexpr char const* kMigratorName = "MPTokenIssuanceHistoryMigrator"; + + /** + * @brief A struct to hold the marker data. + */ + struct Marker { + uint32_t ledger; + uint32_t seq; + }; + + /** + * @brief A struct to hold the output data of the command. + */ + struct Output { + std::string mptIssuanceID; + uint32_t ledgerIndexMin{0}; + uint32_t ledgerIndexMax{0}; + std::optional limit; + std::optional marker; + /** @todo Use a domain-specific type instead of JSON. */ + boost::json::array transactions; + /** @todo Send validated through the RPC framework. */ + bool validated = true; + }; + + /** + * @brief A struct to hold the input data for the command. + * + * @note When no ledger selector is provided, the request uses the backend's full available + * ledger range. + */ + struct Input { + std::string mptIssuanceID; + std::optional account; + std::optional transactionTypeInLowercase; + std::optional ledgerHash; + std::optional ledgerIndex; + std::optional ledgerIndexMin; + std::optional ledgerIndexMax; + bool binary = false; + bool forward = false; + std::optional limit; + std::optional marker; + }; + + using Result = HandlerReturnType; + + /** + * @brief Construct a new MPTokenIssuanceHistoryHandler object. + * + * @param sharedPtrBackend The backend to use. + */ + explicit MPTokenIssuanceHistoryHandler(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 apiVersion. + */ + static RpcSpecConstRef + spec([[maybe_unused]] uint32_t apiVersion) + { + auto const& typesKeysInLowercase = util::getTxTypesInLowercase(); + static auto const kRpcSpec = RpcSpec{ + {JS(mpt_issuance_id), + validation::Required{}, + validation::CustomValidators::uint192HexStringValidator}, + {JS(account), validation::CustomValidators::accountValidator}, + { + "tx_type", + validation::Type{}, + modifiers::ToLower{}, + validation::OneOf( + typesKeysInLowercase.cbegin(), typesKeysInLowercase.cend() + ), + }, + {JS(ledger_hash), validation::CustomValidators::uint256HexStringValidator}, + {JS(ledger_index), validation::CustomValidators::ledgerIndexValidator}, + {JS(ledger_index_min), validation::Type{}}, + {JS(ledger_index_max), validation::Type{}}, + {JS(binary), validation::Type{}}, + {JS(forward), validation::Type{}}, + {JS(limit), + validation::Type{}, + validation::Min(1u), + modifiers::Clamp{kLimitMin, kLimitMax}}, + {JS(marker), + meta::WithCustomError{ + validation::Type{}, + Status{RippledError::RpcInvalidParams, "invalidMarker"} + }, + meta::Section{ + {JS(ledger), validation::Required{}, validation::Type{}}, + {JS(seq), validation::Required{}, validation::Type{}}, + }}, + }; + + return kRpcSpec; + } + + /** + * @brief Process the MPTokenIssuanceHistory command. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @return The result of the operation. + */ + [[nodiscard]] Result + process(Input const& input, Context const& ctx) const; + +private: + /** + * @brief The inclusive range of ledger sequences a request is restricted to. + */ + struct SequenceRange { + uint32_t min; + uint32_t max; + }; + + /** + * @brief Check that the transaction-history backfill has completed. + * + * The terminal Migrated status is cached, so the backend is only consulted until the backfill + * reports completion. + * + * @param ctx The context of the request. + * @return An empty result if the history is complete; an error otherwise. + */ + [[nodiscard]] MaybeError + verifyHistoryAvailable(Context const& ctx) const; + + /** + * @brief Resolve the ledger sequence range to search from the request's ledger specifiers. + * + * Starts from the server's full ledger range, then narrows it by ledger_index_min / + * ledger_index_max, or collapses it to a single sequence if ledger_hash / ledger_index is + * given. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @return The resolved range if the specifiers are valid; an error otherwise. + */ + [[nodiscard]] std::expected + resolveSequenceRange(Input const& input, Context const& ctx) const; + + /** + * @brief Fetch one page of transactions for the issuance, optionally restricted to an account. + * + * When the request has no marker, a forward page starts at the lower bound of @p range and a + * reverse page starts after the highest possible transaction at its upper bound. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @param mptIssuanceID The MPTokenIssuance ID to fetch transactions for. + * @param range The resolved ledger sequence range. + * @return The fetched transactions and the cursor to resume from. + */ + [[nodiscard]] data::TransactionsAndCursor + fetchTransactions( + Input const& input, + Context const& ctx, + xrpl::uint192 const& mptIssuanceID, + SequenceRange range + ) const; + + /** + * @brief Process a fetched transaction page into the response. + * + * Appends transactions that pass the range and type filters. The response marker is taken from + * the fetched page, then cleared if the page runs past the requested range because there is + * nothing left to page through. + * + * @param input The input data for the command. + * @param ctx The context of the request. + * @param range The resolved ledger sequence range. + * @param page The transactions fetched from the database and the cursor to resume from. + * @param [out] response The response to append to. + */ + void + processTransactionsPage( + Input const& input, + Context const& ctx, + SequenceRange range, + data::TransactionsAndCursor const& page, + Output& response + ) const; + + /** + * @brief Convert one transaction to its JSON representation, unless its type is filtered out. + * + * @param txnPlusMeta The transaction and its metadata. + * @param input The input data for the command. + * @param ctx The context of the request. + * @return The JSON representation, or nullopt if the transaction's type does not match the + * requested one. + */ + [[nodiscard]] std::optional + transactionToJsonIfTypeMatches( + data::TransactionAndMetadata const& txnPlusMeta, + Input const& input, + Context const& ctx + ) const; + + /** + * @brief Assemble the API-specific JSON of an expanded transaction and its metadata. + * + * For API v2 and later, the transaction is enriched with ledger information when the + * corresponding ledger header is available. + * + * @param txn The expanded transaction JSON. + * @param meta The expanded metadata JSON. + * @param txnPlusMeta The transaction and its metadata the JSON was expanded from. + * @param ctx The context of the request. + * @return The JSON representation of the transaction. + */ + [[nodiscard]] boost::json::object + expandedTransactionToJson( + boost::json::object txn, + boost::json::object meta, + data::TransactionAndMetadata const& txnPlusMeta, + Context const& ctx + ) const; + + /** + * @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 Marker to a JSON object. + * + * @param [out] jv The JSON object to convert to. + * @param marker The marker to convert. + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Marker const& marker); +}; + +} // namespace rpc diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index e67d37663..e2dc42df0 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -128,6 +128,7 @@ target_sources( rpc/handlers/LedgerRangeTests.cpp rpc/handlers/LedgerTests.cpp rpc/handlers/MPTHoldersTests.cpp + rpc/handlers/MPTokenIssuanceHistoryTests.cpp rpc/handlers/NFTBuyOffersTests.cpp rpc/handlers/NFTHistoryTests.cpp rpc/handlers/NFTInfoTests.cpp diff --git a/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp b/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp new file mode 100644 index 000000000..f2ceed962 --- /dev/null +++ b/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp @@ -0,0 +1,2176 @@ +#include "data/Types.hpp" +#include "migration/MigratiorStatus.hpp" +#include "rpc/Errors.hpp" +#include "rpc/common/AnyHandler.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/handlers/MPTokenIssuanceHistory.hpp" +#include "util/HandlerBaseTestFixture.hpp" +#include "util/LoggerFixtures.hpp" +#include "util/NameGenerator.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace rpc; +using namespace data; +using namespace testing; + +namespace { +constexpr auto kMinSeq = 10; +constexpr auto kMaxSeq = 30; +constexpr auto kAccount = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr auto kAccount2 = "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"; +constexpr auto kLedgerHash = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr auto kCurrency = "0158415500000000C1F76FF6ECB0BAC600000000"; +// Valid 48-hex MPT issuance ID (from MPTHoldersTests.cpp) +constexpr auto kMptId = "000004C463C52827307480341125DA0577DEFC38405B0E3E"; +constexpr auto kMptIdLowercase = "000004c463c52827307480341125da0577defc38405b0e3e"; +constexpr auto kApiVersion = 2; + +auto const kMigratedStatus = + migration::MigratorStatus{migration::MigratorStatus::Status::Migrated}.toString(); +auto const kNotMigratedStatus = + migration::MigratorStatus{migration::MigratorStatus::Status::NotMigrated}.toString(); + +} // namespace + +struct RPCMPTokenIssuanceHistoryHandlerTest : HandlerBaseTest { + RPCMPTokenIssuanceHistoryHandlerTest() + { + backend_->setRange(kMinSeq, kMaxSeq); + ON_CALL(*backend_, fetchMigratorStatus) + .WillByDefault(Return(std::optional{kMigratedStatus})); + } +}; + +/** + * @brief Enables handler log capture below the test runner's default fatal severity. + */ +struct RPCMPTokenIssuanceHistoryHandlerLogTest : RPCMPTokenIssuanceHistoryHandlerTest, + LoggerFixture {}; + +struct MPTokenIssuanceHistoryParamTestCaseBundle { + std::string testName; + std::string testJson; + std::string expectedError; + std::string expectedErrorMessage; +}; + +struct MPTokenIssuanceHistoryParameterTest + : public RPCMPTokenIssuanceHistoryHandlerTest, + public WithParamInterface {}; + +static auto +generateTestValuesForParametersTest() +{ + return std::vector{ + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "MissingMptIssuanceID", + .testJson = R"JSON({})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Required field 'mpt_issuance_id' missing" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "MalformedMptIssuanceID", + .testJson = R"JSON({"mpt_issuance_id": "NOTAHEXSTRING"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "mpt_issuance_idMalformed" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "BinaryNotBool", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "binary": 1})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "ForwardNotBool", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "forward": 1})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LedgerIndexMinNotInt", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index_min": "x"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LedgerIndexMaxNotInt", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index_max": "x"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "BadAccount", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "account": "not_a_valid_account"})JSON", + .expectedError = "actMalformed", + .expectedErrorMessage = "accountMalformed" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "UnknownTxType", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "tx_type": "NotARealType"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid field 'tx_type'." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "MarkerNotObject", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "marker": 101})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "invalidMarker" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "MarkerMissingSeq", + .testJson = R"JSON({ + "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", + "marker": {"ledger": 123} + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Required field 'seq' missing" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "MarkerMissingLedger", + .testJson = R"JSON({ + "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", + "marker": {"seq": 123} + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Required field 'ledger' missing" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LedgerIndexInvalid", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index": "x"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "ledgerIndexMalformed" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LedgerHashInvalid", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_hash": "x"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "ledger_hashMalformed" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LedgerHashNotString", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_hash": 123})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "ledger_hashNotString" + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LimitNotInt", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": "123"})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LimitNegative", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": -1})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + MPTokenIssuanceHistoryParamTestCaseBundle{ + .testName = "LimitZero", + .testJson = + R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": 0})JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Invalid parameters." + }, + }; +} + +INSTANTIATE_TEST_CASE_P( + RPCMPTokenIssuanceHistoryGroup1, + MPTokenIssuanceHistoryParameterTest, + ValuesIn(generateTestValuesForParametersTest()), + tests::util::kNameGenerator +); + +TEST_P(MPTokenIssuanceHistoryParameterTest, InvalidParams) +{ + auto const testBundle = GetParam(); + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::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(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMinOutOfRange) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": 9 + }})JSON", + kMptId + ) + ); + 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(), "lgrIdxMalformed"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMinOutOfRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMaxOutOfRange) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_max": 31 + }})JSON", + kMptId + ) + ); + 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(), "lgrIdxMalformed"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMaxOutOfRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, InvertedLedgerRange) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": 20, + "ledger_index_max": 11 + }})JSON", + kMptId + ) + ); + 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(), "lgrIdxsInvalid"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ContainsLedgerSpecifierAndRange) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": 11, + "ledger_index_max": 20, + "ledger_index": 10 + }})JSON", + kMptId + ) + ); + 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(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateNotMigratedReturnsNotReady) +{ + ON_CALL(*backend_, fetchMigratorStatus) + .WillByDefault(Return(std::optional{kNotMigratedStatus})); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + 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(), "notReady"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateMissingStatusReturnsNotReady) +{ + ON_CALL(*backend_, fetchMigratorStatus).WillByDefault(Return(std::nullopt)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + 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(), "notReady"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateUnknownStatusStringReturnsNotReady) +{ + ON_CALL(*backend_, fetchMigratorStatus) + .WillByDefault(Return(std::optional{"NotAStatus"})); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + 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(), "notReady"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateNotReadyErrorMessageContent) +{ + ON_CALL(*backend_, fetchMigratorStatus) + .WillByDefault(Return(std::optional{kNotMigratedStatus})); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + 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(), "notReady"); + auto const msg = err.at("error_message").as_string(); + EXPECT_TRUE(msg.find("backfill has not completed") != std::string::npos); + // The remedy is the operator's, not the caller's: no CLI invocation in a client-facing + // error. + EXPECT_TRUE(msg.find("clio_server") == std::string::npos); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateMigratedServesRequest) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_TRUE(output.result->as_object().contains("mpt_issuance_id")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateCachedMigratedShortCircuit) +{ + EXPECT_CALL(*backend_, fetchMigratorStatus(MPTokenIssuanceHistoryHandler::kMigratorName, _)) + .Times(1) + .WillOnce(Return(std::optional{kMigratedStatus})); + + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + // Same handler instance across both calls: the cached Migrated flag skips the second check. + auto anyHandler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + + runSpawn([&](auto yield) { + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output1 = anyHandler.process(req, Context{yield}); + ASSERT_TRUE(output1); + auto const output2 = anyHandler.process(req, Context{yield}); + ASSERT_TRUE(output2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateCachedMigratedSurvivesHandlerCopy) +{ + EXPECT_CALL(*backend_, fetchMigratorStatus(MPTokenIssuanceHistoryHandler::kMigratorName, _)) + .Times(1) + .WillOnce(Return(std::optional{kMigratedStatus})); + + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + // Every request gets a copy of the registered handler, so the cached flag survives the copy. + auto original = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const copy = original; + + runSpawn([&](auto yield) { + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = original.process(req, Context{yield}); + ASSERT_TRUE(output); + auto const outputFromCopy = copy.process(req, Context{yield}); + ASSERT_TRUE(outputFromCopy); + }); +} + +static std::vector +genTransactions(uint32_t seq1, uint32_t seq2) +{ + auto transactions = std::vector{}; + + auto trans1 = TransactionAndMetadata(); + xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans1.transaction = obj1.getSerializer().peekData(); + trans1.ledgerSequence = seq1; + xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans1.metadata = meta1.getSerializer().peekData(); + trans1.date = 1; + transactions.push_back(trans1); + + auto trans2 = TransactionAndMetadata(); + xrpl::STObject const obj2 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans2.transaction = obj2.getSerializer().peekData(); + trans2.ledgerSequence = seq2; + xrpl::STObject const meta2 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans2.metadata = meta2.getSerializer().peekData(); + trans2.date = 2; + transactions.push_back(trans2); + + return transactions; +} + +// Build a mixed-type page: one Payment (at seqPayment) and one OfferCreate (at seqOffer). +static std::vector +genMixedTypeTransactions(uint32_t seqPayment, uint32_t seqOffer) +{ + auto transactions = std::vector{}; + + auto payment = TransactionAndMetadata(); + xrpl::STObject const paymentObj = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + payment.transaction = paymentObj.getSerializer().peekData(); + payment.ledgerSequence = seqPayment; + xrpl::STObject const paymentMeta = + createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + payment.metadata = paymentMeta.getSerializer().peekData(); + payment.date = 1; + transactions.push_back(payment); + + auto offer = TransactionAndMetadata(); + xrpl::STObject const offerObj = + createCreateOfferTransactionObject(kAccount, 2, 100, kCurrency, kAccount2, 200, 300); + offer.transaction = offerObj.getSerializer().peekData(); + offer.ledgerSequence = seqOffer; + xrpl::STObject const offerMeta = + createMetaDataForCreateOffer(kCurrency, kAccount, 100, 200, 300); + offer.metadata = offerMeta.getSerializer().peekData(); + offer.date = 2; + transactions.push_back(offer); + + return transactions; +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, RoutingWithoutAccountCallsFetchMPTIssuanceTxns) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(1); + EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, RoutingWithAccountCallsFetchAccountMPTIssuanceTxns) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(1); + EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format(R"JSON({{"mpt_issuance_id": "{}", "account": "{}"}})JSON", kMptId, kAccount) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardCursorSeedFromMinIndex) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 1, 0})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": true + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq + 1); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ReverseCursorSeedFromMaxIndex) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, NoIndexSpecifiedForwardSeedsFromGlobalMin) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq, 0})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "forward": true + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, NoIndexSpecifiedReverseSeedsFromGlobalMax) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "forward": false + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, IndexSpecificForwardFalseV1) +{ + constexpr auto kOutput = R"JSON({ + "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", + "ledger_index_min": 11, + "ledger_index_max": 29, + "transactions": [ + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Balance": "22" + }, + "LedgerEntryType": "AccountRoot" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Balance": "23" + }, + "LedgerEntryType": "AccountRoot" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "unavailable" + }, + "tx": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Amount": "1", + "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Fee": "1", + "Sequence": 32, + "SigningPubKey": "74657374", + "TransactionType": "Payment", + "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", + "DeliverMax": "1", + "ledger_index": 11, + "date": 1 + }, + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Balance": "22" + }, + "LedgerEntryType": "AccountRoot" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Balance": "23" + }, + "LedgerEntryType": "AccountRoot" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "unavailable" + }, + "tx": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Amount": "1", + "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Fee": "1", + "Sequence": 32, + "SigningPubKey": "74657374", + "TransactionType": "Payment", + "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", + "DeliverMax": "1", + "ledger_index": 29, + "date": 2 + }, + "validated": true + } + ], + "validated": true, + "marker": { + "ledger": 12, + "seq": 34 + } + })JSON"; + + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result.value(), boost::json::parse(kOutput)); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, IndexSpecificForwardFalseV2) +{ + constexpr auto kOutput = R"JSON({ + "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", + "ledger_index_min": 11, + "ledger_index_max": 29, + "transactions": [ + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Balance": "22" + }, + "LedgerEntryType": "AccountRoot" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Balance": "23" + }, + "LedgerEntryType": "AccountRoot" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "unavailable" + }, + "tx_json": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Fee": "1", + "Sequence": 32, + "SigningPubKey": "74657374", + "TransactionType": "Payment", + "DeliverMax": "1", + "ledger_index": 11, + "date": 1 + }, + "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", + "ledger_index": 11, + "close_time_iso": "2000-01-01T00:00:00Z", + "ledger_hash": "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", + "validated": true + }, + { + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Balance": "22" + }, + "LedgerEntryType": "AccountRoot" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Balance": "23" + }, + "LedgerEntryType": "AccountRoot" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "unavailable" + }, + "tx_json": { + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", + "Fee": "1", + "Sequence": 32, + "SigningPubKey": "74657374", + "TransactionType": "Payment", + "DeliverMax": "1", + "ledger_index": 29, + "date": 2 + }, + "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", + "ledger_index": 29, + "close_time_iso": "2000-01-01T00:00:00Z", + "ledger_hash": "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", + "validated": true + } + ], + "validated": true, + "marker": { + "ledger": 12, + "seq": 34 + } + })JSON"; + + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .WillOnce(Return(transCursor)); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq); + ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(ledgerHeader)); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(2); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 1 + ) + ); + auto const output = + handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result.value(), boost::json::parse(kOutput)); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryTrueV1) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "binary": true + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + auto const& firstTx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_TRUE(firstTx.contains("tx_blob")); + EXPECT_TRUE(firstTx.contains("meta")); + EXPECT_TRUE(firstTx.contains("ledger_index")); + EXPECT_TRUE(firstTx.contains("date")); + EXPECT_TRUE(firstTx.contains("validated")); + EXPECT_FALSE(output.result->as_object().contains("limit")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryTrueV2) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ + ) + ) + .WillOnce(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "binary": true + }})JSON", + kMptId + ) + ); + auto const output = + handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + auto const& firstTx = output.result->at("transactions").as_array()[0].as_object(); + // V2 binary uses meta_blob instead of meta + EXPECT_TRUE(firstTx.contains("tx_blob")); + EXPECT_TRUE(firstTx.contains("meta_blob")); + EXPECT_TRUE(firstTx.contains("ledger_index")); + EXPECT_TRUE(firstTx.contains("date")); + EXPECT_TRUE(firstTx.contains("validated")); + EXPECT_EQ( + firstTx.at("meta_blob").as_string(), + "201C00000000F8E5110061E762400000000000001681144B4E9C06F24296074F7B" + "C48F92A97916C6DC5EA9E1E1E5110061E76240000000000000178114D31252CF90" + "2EF8DD8451243869B38667CBD89DF3E1E1F1031000" + ); + EXPECT_EQ( + firstTx.at("tx_blob").as_string(), + "120000240000002061400000000000000168400000000000000173047465737481" + "144B4E9C06F24296074F7BC48F92A97916C6DC5EA98314D31252CF902EF8DD8451" + "243869B38667CBD89DF3" + ); + EXPECT_FALSE(output.result->as_object().contains("limit")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitAndMarkerRoundTrip) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, false, testing::Optional(testing::Eq(TransactionsCursor{10, 11})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "limit": 2, + "forward": false, + "marker": {{"ledger": 10, "seq": 11}} + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_EQ(output.result->at("limit").as_uint64(), 2); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitMoreThanMax) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false, + "limit": {} + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 1, + MPTokenIssuanceHistoryHandler::kLimitMax + 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("limit").as_uint64(), MPTokenIssuanceHistoryHandler::kLimitMax); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitNotSetDefaultUsedAndNotInResponse) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_FALSE(output.result->as_object().contains("limit")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxBelowMinSeqClipped) +{ + // Reverse: first tx at kMaxSeq-1 is in range, second at kMinSeq+1 is below minIndex. + auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false + }})JSON", + kMptId, + kMinSeq + 2, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxAboveMaxSeqClipped) +{ + // Reverse: first tx at kMaxSeq-1 is above maxIndex, second at kMinSeq+1 is in range. + auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 2, INT32_MAX})), + _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": false + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 2 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificLedgerIndex) +{ + // reverse traversal; first tx at kMaxSeq-1 (= ledger_index), second below => dropped + auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .Times(1); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); + ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(ledgerHeader)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index": {} + }})JSON", + kMptId, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + EXPECT_FALSE(output.result->as_object().contains("limit")); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificNonExistLedgerIndex) +{ + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); + ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(std::nullopt)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index": {} + }})JSON", + kMptId, + kMaxSeq - 1 + ) + ); + 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(), "lgrNotFound"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificLedgerHash) +{ + auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, + _, + false, + testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), + _ + ) + ) + .Times(1); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); + EXPECT_CALL(*backend_, fetchLedgerByHash).Times(1); + ON_CALL(*backend_, fetchLedgerByHash(xrpl::uint256{kLedgerHash}, _)) + .WillByDefault(Return(ledgerHeader)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}" + }})JSON", + kMptId, + kLedgerHash + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + EXPECT_FALSE(output.result->as_object().contains("limit")); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, EmptyResultForUnseenId) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_TRUE(output.result->at("transactions").as_array().empty()); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, EmptyResultForUnseenIdWithAccount) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format(R"JSON({{"mpt_issuance_id": "{}", "account": "{}"}})JSON", kMptId, kAccount) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + EXPECT_TRUE(output.result->at("transactions").as_array().empty()); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingBlobMidPageSkipped) +{ + auto transactions = std::vector{}; + + auto trans1 = TransactionAndMetadata(); + xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans1.transaction = obj1.getSerializer().peekData(); + trans1.ledgerSequence = kMinSeq + 1; + xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans1.metadata = meta1.getSerializer().peekData(); + trans1.date = 1; + transactions.push_back(trans1); + + auto emptyTrans = TransactionAndMetadata(); + emptyTrans.ledgerSequence = kMinSeq + 2; + emptyTrans.date = 2; + transactions.push_back(emptyTrans); + + auto trans3 = TransactionAndMetadata(); + xrpl::STObject const obj3 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans3.transaction = obj3.getSerializer().peekData(); + trans3.ledgerSequence = kMaxSeq - 1; + xrpl::STObject const meta3 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans3.metadata = meta3.getSerializer().peekData(); + trans3.date = 3; + transactions.push_back(trans3); + + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": -1, + "ledger_index_max": -1, + "forward": false + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingBlobInBinaryModeSkipped) +{ + // Page of [valid, empty, valid]: the empty record is skipped, marker unaffected. + auto transactions = std::vector{}; + + auto trans1 = TransactionAndMetadata(); + xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans1.transaction = obj1.getSerializer().peekData(); + trans1.ledgerSequence = kMaxSeq - 1; + xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans1.metadata = meta1.getSerializer().peekData(); + trans1.date = 1; + transactions.push_back(trans1); + + auto emptyTrans = TransactionAndMetadata(); + emptyTrans.ledgerSequence = kMinSeq + 2; + emptyTrans.date = 2; + transactions.push_back(emptyTrans); + + auto trans3 = TransactionAndMetadata(); + xrpl::STObject const obj3 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans3.transaction = obj3.getSerializer().peekData(); + trans3.ledgerSequence = kMinSeq + 1; + xrpl::STObject const meta3 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans3.metadata = meta3.getSerializer().peekData(); + trans3.date = 3; + transactions.push_back(trans3); + + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{5, 6}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "binary": true + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + for (auto const& tx : output.result->at("transactions").as_array()) + EXPECT_TRUE(tx.as_object().contains("tx_blob")); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 5, "seq": 6})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterSparseMixedPageKeepsMarker) +{ + // Filtering 1 of 2 yields a sparse page, but the marker rides the raw page boundary. + auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "tx_type": "Payment" + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); + auto const& tx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_EQ(tx.at("tx").as_object().at("TransactionType").as_string(), "Payment"); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterNonMatchReturnsEmpty) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "tx_type": "OfferCreate" + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 0); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterCaseInsensitive) +{ + // ToLower modifier means mixed-case "pAyMeNt" still matches. + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "tx_type": "pAyMeNt" + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterSparseMixedPageWithAccountKeepsMarker) +{ + // As the non-account sparse-page test, but via the account routing path. + auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(1); + EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "account": "{}", + "tx_type": "Payment" + }})JSON", + kMptId, + kAccount + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); + auto const& tx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_EQ(tx.at("tx").as_object().at("TransactionType").as_string(), "Payment"); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterWithAccountNonMatchReturnsEmpty) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "account": "{}", + "tx_type": "OfferCreate" + }})JSON", + kMptId, + kAccount + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 0); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardTxAboveMaxSeqClippedAndMarkerCleared) +{ + // Forward: second tx exceeds maxIndex, so it is dropped and the marker cleared. + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 1, 0})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": true + }})JSON", + kMptId, + kMinSeq + 1, + kMaxSeq - 2 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryWithTxTypeFilterV1) +{ + // binary + tx_type: expand to filter by type, then emit the survivors in binary form. + auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "binary": true, + "tx_type": "Payment" + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); + auto const& tx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_TRUE(tx.contains("tx_blob")); + EXPECT_TRUE(tx.contains("meta")); + EXPECT_TRUE(tx.contains("ledger_index")); + EXPECT_TRUE(tx.contains("date")); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryWithTxTypeFilterV2) +{ + // As above, but V2 binary uses meta_blob. + auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "binary": true, + "tx_type": "Payment" + }})JSON", + kMptId + ) + ); + auto const output = + handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); + auto const& tx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_TRUE(tx.contains("tx_blob")); + EXPECT_TRUE(tx.contains("meta_blob")); + EXPECT_TRUE(tx.contains("ledger_index")); + EXPECT_TRUE(tx.contains("date")); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ResponseAlwaysHasMandatoryFields) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + auto const& obj = output.result->as_object(); + EXPECT_TRUE(obj.contains("mpt_issuance_id")); + EXPECT_TRUE(obj.contains("ledger_index_min")); + EXPECT_TRUE(obj.contains("ledger_index_max")); + EXPECT_TRUE(obj.contains("transactions")); + EXPECT_TRUE(obj.contains("validated")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMinAboveRangeMax) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {} + }})JSON", + kMptId, + kMaxSeq + 1 + ) + ); + 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(), "lgrIdxMalformed"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMinOutOfRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMaxBelowRangeMin) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_max": {} + }})JSON", + kMptId, + kMinSeq - 1 + ) + ); + 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(), "lgrIdxMalformed"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMaxOutOfRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerHashWithOnlyLedgerIndexMin) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}", + "ledger_index_min": {} + }})JSON", + kMptId, + kLedgerHash, + kMinSeq + 1 + ) + ); + 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(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerHashWithOnlyLedgerIndexMax) +{ + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_hash": "{}", + "ledger_index_max": {} + }})JSON", + kMptId, + kLedgerHash, + kMaxSeq - 1 + ) + ); + 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(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexValidatedStringIsNotASpecifier) +{ + // "validated" resolves to no concrete index, so the request keeps the full ledger range. + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); + EXPECT_CALL(*backend_, fetchLedgerByHash).Times(0); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index": "validated" + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexNumericStringAccepted) +{ + auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); + ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(ledgerHeader)); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index": "{}" + }})JSON", + kMptId, + kMaxSeq - 1 + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingMetadataOnlySkipped) +{ + // Page of [no metadata, valid]: the incomplete record is skipped, marker unaffected. + auto transactions = std::vector{}; + + auto noMeta = TransactionAndMetadata(); + xrpl::STObject const noMetaObj = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + noMeta.transaction = noMetaObj.getSerializer().peekData(); + noMeta.ledgerSequence = kMaxSeq - 1; + noMeta.date = 1; + transactions.push_back(noMeta); + + auto complete = TransactionAndMetadata(); + xrpl::STObject const completeObj = + createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + complete.transaction = completeObj.getSerializer().peekData(); + complete.ledgerSequence = kMinSeq + 1; + xrpl::STObject const completeMeta = + createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + complete.metadata = completeMeta.getSerializer().peekData(); + complete.date = 2; + transactions.push_back(complete); + + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); + auto const& tx = output.result->at("transactions").as_array()[0].as_object(); + EXPECT_EQ(tx.at("tx").as_object().at("ledger_index").as_uint64(), kMinSeq + 1); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, V2MissingLedgerHeaderOmitsCloseTimeAndLedgerHash) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(std::nullopt)); + EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(2); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = + handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); + ASSERT_TRUE(output); + ASSERT_EQ(output.result->at("transactions").as_array().size(), 2); + for (auto const& tx : output.result->at("transactions").as_array()) { + auto const& obj = tx.as_object(); + EXPECT_FALSE(obj.contains("close_time_iso")); + EXPECT_FALSE(obj.contains("ledger_hash")); + EXPECT_TRUE(obj.contains("hash")); + EXPECT_TRUE(obj.contains("tx_json")); + EXPECT_TRUE(obj.contains("ledger_index")); + EXPECT_TRUE(obj.at("validated").as_bool()); + } + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardTxBelowMinSeqNotClipped) +{ + // Forward traversal only clips above maxIndex; one below minIndex is kept, as account_tx does. + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 2, 0})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "ledger_index_min": {}, + "ledger_index_max": {}, + "forward": true + }})JSON", + kMptId, + kMinSeq + 2, + kMaxSeq + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MarkerSeedsCursorInForwardMode) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, _, true, testing::Optional(testing::Eq(TransactionsCursor{21, 22})), _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "forward": true, + "marker": {{"ledger": 21, "seq": 22}} + }})JSON", + kMptId + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_FALSE(output.result->as_object().contains("marker")); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitAtMinIsPassedThrough) +{ + auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + auto const transCursor = + TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, static_cast(MPTokenIssuanceHistoryHandler::kLimitMin), _, _, _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format( + R"JSON({{ + "mpt_issuance_id": "{}", + "limit": {} + }})JSON", + kMptId, + MPTokenIssuanceHistoryHandler::kLimitMin + ) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("limit").as_uint64(), MPTokenIssuanceHistoryHandler::kLimitMin); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, DefaultLimitPassedToBackend) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + EXPECT_CALL( + *backend_, + fetchMPTokenIssuanceTransactions( + _, static_cast(MPTokenIssuanceHistoryHandler::kLimitDefault), _, _, _ + ) + ) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MptIssuanceIDIsNormalizedInOutput) +{ + auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; + ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); + + EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions(xrpl::uint192{kMptId}, _, _, _, _)) + .Times(1); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = boost::json::parse( + fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptIdLowercase) + ); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); + }); +} + +TEST_F(RPCMPTokenIssuanceHistoryHandlerLogTest, LogsFetchDurationAndSkippedIndexEntry) +{ + auto transactions = std::vector{}; + + auto trans1 = TransactionAndMetadata(); + xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); + trans1.transaction = obj1.getSerializer().peekData(); + trans1.ledgerSequence = kMinSeq + 1; + xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); + trans1.metadata = meta1.getSerializer().peekData(); + trans1.date = 1; + transactions.push_back(trans1); + + auto emptyTrans = TransactionAndMetadata(); + emptyTrans.ledgerSequence = kMinSeq + 2; + emptyTrans.date = 2; + transactions.push_back(emptyTrans); + + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; + EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillOnce(Return(transCursor)); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; + auto const req = + boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); + }); + + auto const logs = getLoggerString(); + EXPECT_THAT( + logs, ContainsRegex(R"(inf:RPC - db fetch took [0-9]+ milliseconds - num blobs = 2)") + ); + EXPECT_THAT( + logs, + HasSubstr( + fmt::format( + "war:RPC - Skipping index entry with no matching transaction record; " + "mpt_issuance_id = {}", + kMptId + ) + ) + ); +} From 8e091871cba90d54fe2cb1ef337677ac7147a29f Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 15:42:22 +0100 Subject: [PATCH 2/4] chore: Revert "feat: Add mptoken_issuance_history RPC" (#3183) --- src/rpc/CMakeLists.txt | 1 - src/rpc/RPCCenter.cpp | 1 - src/rpc/common/impl/HandlerProvider.cpp | 3 - src/rpc/handlers/MPTokenIssuanceHistory.cpp | 378 --- src/rpc/handlers/MPTokenIssuanceHistory.hpp | 316 --- tests/unit/CMakeLists.txt | 1 - .../handlers/MPTokenIssuanceHistoryTests.cpp | 2176 ----------------- 7 files changed, 2876 deletions(-) delete mode 100644 src/rpc/handlers/MPTokenIssuanceHistory.cpp delete mode 100644 src/rpc/handlers/MPTokenIssuanceHistory.hpp delete mode 100644 tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index e9ca6dc81..9691de44d 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -44,7 +44,6 @@ target_sources( handlers/LedgerIndex.cpp handlers/LedgerRange.cpp handlers/MPTHolders.cpp - handlers/MPTokenIssuanceHistory.cpp handlers/NFTsByIssuer.cpp handlers/NFTBuyOffers.cpp handlers/NFTHistory.cpp diff --git a/src/rpc/RPCCenter.cpp b/src/rpc/RPCCenter.cpp index 22fa9d8b9..8280ded91 100644 --- a/src/rpc/RPCCenter.cpp +++ b/src/rpc/RPCCenter.cpp @@ -35,7 +35,6 @@ handledRpcs() "ledger_index", "ledger_range", "mpt_holders", - "mptoken_issuance_history", "nfts_by_issuer", "nft_history", "nft_buy_offers", diff --git a/src/rpc/common/impl/HandlerProvider.cpp b/src/rpc/common/impl/HandlerProvider.cpp index c199753c8..dae6121f2 100644 --- a/src/rpc/common/impl/HandlerProvider.cpp +++ b/src/rpc/common/impl/HandlerProvider.cpp @@ -30,7 +30,6 @@ #include "rpc/handlers/LedgerIndex.hpp" #include "rpc/handlers/LedgerRange.hpp" #include "rpc/handlers/MPTHolders.hpp" -#include "rpc/handlers/MPTokenIssuanceHistory.hpp" #include "rpc/handlers/NFTBuyOffers.hpp" #include "rpc/handlers/NFTHistory.hpp" #include "rpc/handlers/NFTInfo.hpp" @@ -92,8 +91,6 @@ ProductionHandlerProvider::ProductionHandlerProvider( {"ledger_range", {.handler = LedgerRangeHandler{backend}}}, {"mpt_holders", {.handler = MPTHoldersHandler{backend}, .isClioOnly = true}}, // clio only - {"mptoken_issuance_history", - {.handler = MPTokenIssuanceHistoryHandler{backend}, .isClioOnly = true}}, // clio only {"nfts_by_issuer", {.handler = NFTsByIssuerHandler{backend}, .isClioOnly = true}}, // clio only {"nft_history", diff --git a/src/rpc/handlers/MPTokenIssuanceHistory.cpp b/src/rpc/handlers/MPTokenIssuanceHistory.cpp deleted file mode 100644 index 813528ed3..000000000 --- a/src/rpc/handlers/MPTokenIssuanceHistory.cpp +++ /dev/null @@ -1,378 +0,0 @@ -#include "rpc/handlers/MPTokenIssuanceHistory.hpp" - -#include "data/Types.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 "util/Profiler.hpp" -#include "util/log/Logger.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace rpc { - -namespace { - -/** - * @brief The migrator status that reports a completed backfill. - * - * This is a literal to keep migration headers out of RPC; it must match the string form of - * `migration::MigratorStatus::Status::Migrated`. - */ -constexpr auto kMigratedStatus = "Migrated"; - -} // namespace - -MPTokenIssuanceHistoryHandler::Result -MPTokenIssuanceHistoryHandler::process( - MPTokenIssuanceHistoryHandler::Input const& input, - Context const& ctx -) const -{ - if (auto const available = verifyHistoryAvailable(ctx); not available.has_value()) - return Error{available.error()}; - - auto const range = resolveSequenceRange(input, ctx); - if (not range.has_value()) - return Error{range.error()}; - - auto const mptIssuanceID = xrpl::uint192{input.mptIssuanceID.c_str()}; - - auto const [page, timeDiff] = - util::timed([&] { return fetchTransactions(input, ctx, mptIssuanceID, *range); }); - LOG(log_.info()) << "db fetch took " << timeDiff - << " milliseconds - num blobs = " << page.txns.size(); - - Output response; - response.mptIssuanceID = xrpl::to_string(mptIssuanceID); - response.ledgerIndexMin = range->min; - response.ledgerIndexMax = range->max; - response.limit = input.limit; - - processTransactionsPage(input, ctx, *range, page, response); - - return response; -} - -MaybeError -MPTokenIssuanceHistoryHandler::verifyHistoryAvailable(Context const& ctx) const -{ - if (migrated_->load(std::memory_order_relaxed)) - return {}; - - auto const statusString = sharedPtrBackend_->fetchMigratorStatus(kMigratorName, ctx.yield); - if (statusString.has_value() and *statusString == kMigratedStatus) { - migrated_->store(true, std::memory_order_relaxed); - return {}; - } - - // Fail closed: partial history must never be served. - return Error{Status{ - RippledError::RpcNotReady, - "mptoken_issuance_history is not available on this server because the required " - "transaction-history backfill has not completed." - }}; -} - -std::expected -MPTokenIssuanceHistoryHandler::resolveSequenceRange(Input const& input, Context const& ctx) const -{ - auto const ledgerRange = sharedPtrBackend_->fetchLedgerRange(); - ASSERT(ledgerRange.has_value(), "MPTokenIssuanceHistory's ledger range must be available"); - - auto const [dbMinSeq, dbMaxSeq] = *ledgerRange; // NOLINT(bugprone-unchecked-optional-access) - auto resolved = SequenceRange{.min = dbMinSeq, .max = dbMaxSeq}; - - if (input.ledgerIndexMin.has_value()) { - if (dbMaxSeq < input.ledgerIndexMin || dbMinSeq > input.ledgerIndexMin) - return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMinOutOfRange"}}; - - resolved.min = *input.ledgerIndexMin; - } - - if (input.ledgerIndexMax.has_value()) { - if (dbMaxSeq < input.ledgerIndexMax || dbMinSeq > input.ledgerIndexMax) - return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMaxOutOfRange"}}; - - resolved.max = *input.ledgerIndexMax; - } - - if (resolved.min > resolved.max) - return Error{Status{RippledError::RpcLgrIdxsInvalid}}; - - if (input.ledgerHash.has_value() || input.ledgerIndex.has_value()) { - // rippled does not have this check - if (input.ledgerIndexMax.has_value() || input.ledgerIndexMin.has_value()) - return Error{Status{RippledError::RpcInvalidParams, "containsLedgerSpecifierAndRange"}}; - - auto const expectedLgrInfo = getLedgerHeaderFromHashOrSeq( - *sharedPtrBackend_, ctx.yield, input.ledgerHash, input.ledgerIndex, dbMaxSeq - ); - - if (not expectedLgrInfo.has_value()) - return Error{expectedLgrInfo.error()}; - - resolved.max = resolved.min = expectedLgrInfo->seq; - } - - return resolved; -} - -data::TransactionsAndCursor -MPTokenIssuanceHistoryHandler::fetchTransactions( - Input const& input, - Context const& ctx, - xrpl::uint192 const& mptIssuanceID, - SequenceRange range -) const -{ - // Construct the database cursor as {ledgerSequence, transactionIndex}. - auto const startCursor = [&]() -> data::TransactionsCursor { - if (input.marker.has_value()) - return {input.marker->ledger, input.marker->seq}; - - // Forward iteration starts at the first possible transaction in the lowest ledger. - if (input.forward) - return {range.min, 0}; - - // Reverse iteration starts after all possible transactions in the highest ledger. - return {range.max, std::numeric_limits::max()}; - }(); - - auto const limit = input.limit.value_or(kLimitDefault); - - // tx_type is applied post-fetch, as account_tx does. - if (input.account.has_value()) { - auto const account = accountFromStringStrict(*input.account); - if (not account.has_value()) { - ASSERT(false, "Account must be decodable after spec validation"); - std::unreachable(); - } - return sharedPtrBackend_->fetchAccountMPTokenIssuanceTransactions( - mptIssuanceID, *account, limit, input.forward, startCursor, ctx.yield - ); - } - - return sharedPtrBackend_->fetchMPTokenIssuanceTransactions( - mptIssuanceID, limit, input.forward, startCursor, ctx.yield - ); -} - -void -MPTokenIssuanceHistoryHandler::processTransactionsPage( - Input const& input, - Context const& ctx, - SequenceRange range, - data::TransactionsAndCursor const& page, - Output& response -) const -{ - if (page.cursor.has_value()) { - response.marker = { - .ledger = page.cursor->ledgerSequence, .seq = page.cursor->transactionIndex - }; - } - - for (auto const& txnPlusMeta : page.txns) { - // A hash with no matching Transactions row yields a default-constructed record in-position. - // Skip it before the range check so it neither shortens the page nor disturbs the marker. - if (txnPlusMeta.transaction.empty() || txnPlusMeta.metadata.empty()) { - LOG(log_.warn()) << "Skipping index entry with no matching transaction record; " - "mpt_issuance_id = " - << input.mptIssuanceID; - continue; - } - - // Stop once iteration passes the far edge of the requested range. - if ((txnPlusMeta.ledgerSequence < range.min && !input.forward) || - (txnPlusMeta.ledgerSequence > range.max && input.forward)) { - response.marker = std::nullopt; - break; - } - if (txnPlusMeta.ledgerSequence > range.max && !input.forward) { - LOG(log_.debug()) << "Skipping over transactions from incomplete ledger"; - continue; - } - - if (auto obj = transactionToJsonIfTypeMatches(txnPlusMeta, input, ctx); obj.has_value()) - response.transactions.push_back(std::move(*obj)); - } -} - -std::optional -MPTokenIssuanceHistoryHandler::transactionToJsonIfTypeMatches( - data::TransactionAndMetadata const& txnPlusMeta, - Input const& input, - Context const& ctx -) const -{ - // The type filter needs the expanded form to read TransactionType, even for binary output. - if (!input.binary || input.transactionTypeInLowercase.has_value()) { - auto [txn, meta] = toExpandedJson(txnPlusMeta, ctx.apiVersion); - - if (txn.contains(JS(TransactionType)) && input.transactionTypeInLowercase.has_value() && - util::toLower(boost::json::value_to(txn[JS(TransactionType)])) != - *input.transactionTypeInLowercase) - return std::nullopt; - - if (!input.binary) - return expandedTransactionToJson(std::move(txn), std::move(meta), txnPlusMeta, ctx); - } - - auto obj = toJsonWithBinaryTx(txnPlusMeta, ctx.apiVersion); - obj[JS(ledger_index)] = txnPlusMeta.ledgerSequence; - obj[JS(date)] = txnPlusMeta.date; - obj[JS(validated)] = true; - - return obj; -} - -boost::json::object -MPTokenIssuanceHistoryHandler::expandedTransactionToJson( - boost::json::object txn, - boost::json::object meta, - data::TransactionAndMetadata const& txnPlusMeta, - Context const& ctx -) const -{ - auto const txKey = ctx.apiVersion > 1u ? JS(tx_json) : JS(tx); - - boost::json::object obj; - obj[JS(meta)] = std::move(meta); - obj[txKey] = std::move(txn); - obj[txKey].as_object()[JS(ledger_index)] = txnPlusMeta.ledgerSequence; - obj[txKey].as_object()[JS(date)] = txnPlusMeta.date; - - if (ctx.apiVersion > 1u) { - obj[JS(ledger_index)] = txnPlusMeta.ledgerSequence; - if (obj[txKey].as_object().contains(JS(hash))) { - obj[JS(hash)] = obj[txKey].at(JS(hash)); - obj[txKey].as_object().erase(JS(hash)); - } - if (auto const lgrInfo = - sharedPtrBackend_->fetchLedgerBySequence(txnPlusMeta.ledgerSequence, ctx.yield); - lgrInfo.has_value()) { - obj[JS(close_time_iso)] = xrpl::toStringIso(lgrInfo->closeTime); - obj[JS(ledger_hash)] = xrpl::strHex(lgrInfo->hash); - } - } - - obj[JS(validated)] = true; - - return obj; -} - -void -tag_invoke( - boost::json::value_from_tag, - boost::json::value& jv, - MPTokenIssuanceHistoryHandler::Output const& output -) -{ - jv = { - {JS(mpt_issuance_id), output.mptIssuanceID}, - {JS(ledger_index_min), output.ledgerIndexMin}, - {JS(ledger_index_max), output.ledgerIndexMax}, - {JS(transactions), output.transactions}, - {JS(validated), output.validated}, - }; - - if (output.marker.has_value()) - jv.as_object()[JS(marker)] = boost::json::value_from(*(output.marker)); - - if (output.limit.has_value()) - jv.as_object()[JS(limit)] = *(output.limit); -} - -void -tag_invoke( - boost::json::value_from_tag, - boost::json::value& jv, - MPTokenIssuanceHistoryHandler::Marker const& marker -) -{ - jv = { - {JS(ledger), marker.ledger}, - {JS(seq), marker.seq}, - }; -} - -MPTokenIssuanceHistoryHandler::Input -tag_invoke( - boost::json::value_to_tag, - boost::json::value const& jv -) -{ - auto const& jsonObject = jv.as_object(); - auto input = MPTokenIssuanceHistoryHandler::Input{}; - - input.mptIssuanceID = boost::json::value_to(jsonObject.at(JS(mpt_issuance_id))); - - if (jsonObject.contains(JS(account))) - input.account = boost::json::value_to(jsonObject.at(JS(account))); - - if (jsonObject.contains("tx_type")) { - input.transactionTypeInLowercase = - boost::json::value_to(jsonObject.at("tx_type")); - } - - if (jsonObject.contains(JS(ledger_index_min)) && - util::integralValueAs(jsonObject.at(JS(ledger_index_min))) != -1) - input.ledgerIndexMin = util::integralValueAs(jsonObject.at(JS(ledger_index_min))); - - if (jsonObject.contains(JS(ledger_index_max)) && - util::integralValueAs(jsonObject.at(JS(ledger_index_max))) != -1) - input.ledgerIndexMax = util::integralValueAs(jsonObject.at(JS(ledger_index_max))); - - if (jsonObject.contains(JS(ledger_hash))) - input.ledgerHash = boost::json::value_to(jsonObject.at(JS(ledger_hash))); - - if (jsonObject.contains(JS(ledger_index))) { - auto const expectedLedgerIndex = util::getLedgerIndex(jsonObject.at(JS(ledger_index))); - if (expectedLedgerIndex.has_value()) - input.ledgerIndex = *expectedLedgerIndex; - } - - if (jsonObject.contains(JS(binary))) - input.binary = jsonObject.at(JS(binary)).as_bool(); - - if (jsonObject.contains(JS(forward))) - input.forward = jsonObject.at(JS(forward)).as_bool(); - - if (jsonObject.contains(JS(limit))) - input.limit = util::integralValueAs(jsonObject.at(JS(limit))); - - if (jsonObject.contains(JS(marker))) { - input.marker = MPTokenIssuanceHistoryHandler::Marker{ - .ledger = util::integralValueAs( - jsonObject.at(JS(marker)).as_object().at(JS(ledger)) - ), - .seq = - util::integralValueAs(jsonObject.at(JS(marker)).as_object().at(JS(seq))) - }; - } - - return input; -} - -} // namespace rpc diff --git a/src/rpc/handlers/MPTokenIssuanceHistory.hpp b/src/rpc/handlers/MPTokenIssuanceHistory.hpp deleted file mode 100644 index b5f565bff..000000000 --- a/src/rpc/handlers/MPTokenIssuanceHistory.hpp +++ /dev/null @@ -1,316 +0,0 @@ -#pragma once - -#include "data/BackendInterface.hpp" -#include "data/Types.hpp" -#include "rpc/Errors.hpp" -#include "rpc/JS.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 "util/TxUtils.hpp" -#include "util/log/Logger.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace rpc { - -/** - * @brief The mptoken_issuance_history command returns past transactions associated with the queried - * MPTokenIssuance, optionally filtered by an affected account and/or transaction type. - * - * @note This is a Clio-only method. Requests fail with `notReady` until the issuance-history - * backfill reports `Migrated`, so partial history is never served. - */ -class MPTokenIssuanceHistoryHandler { - util::Logger log_{"RPC"}; - std::shared_ptr sharedPtrBackend_; - - /** - * @brief Whether the issuance-history backfill has completed. - * - * The status is monotonic, so the terminal result is cached across handler copies. - */ - std::shared_ptr migrated_ = std::make_shared(false); - -public: - static constexpr auto kLimitMin = 1; - static constexpr auto kLimitMax = 100; - static constexpr auto kLimitDefault = 50; - - /** - * @brief The name used to query the issuance-history migrator's status. - * - * This is a literal to keep Cassandra migration headers out of RPC. - */ - static constexpr char const* kMigratorName = "MPTokenIssuanceHistoryMigrator"; - - /** - * @brief A struct to hold the marker data. - */ - struct Marker { - uint32_t ledger; - uint32_t seq; - }; - - /** - * @brief A struct to hold the output data of the command. - */ - struct Output { - std::string mptIssuanceID; - uint32_t ledgerIndexMin{0}; - uint32_t ledgerIndexMax{0}; - std::optional limit; - std::optional marker; - /** @todo Use a domain-specific type instead of JSON. */ - boost::json::array transactions; - /** @todo Send validated through the RPC framework. */ - bool validated = true; - }; - - /** - * @brief A struct to hold the input data for the command. - * - * @note When no ledger selector is provided, the request uses the backend's full available - * ledger range. - */ - struct Input { - std::string mptIssuanceID; - std::optional account; - std::optional transactionTypeInLowercase; - std::optional ledgerHash; - std::optional ledgerIndex; - std::optional ledgerIndexMin; - std::optional ledgerIndexMax; - bool binary = false; - bool forward = false; - std::optional limit; - std::optional marker; - }; - - using Result = HandlerReturnType; - - /** - * @brief Construct a new MPTokenIssuanceHistoryHandler object. - * - * @param sharedPtrBackend The backend to use. - */ - explicit MPTokenIssuanceHistoryHandler(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 apiVersion. - */ - static RpcSpecConstRef - spec([[maybe_unused]] uint32_t apiVersion) - { - auto const& typesKeysInLowercase = util::getTxTypesInLowercase(); - static auto const kRpcSpec = RpcSpec{ - {JS(mpt_issuance_id), - validation::Required{}, - validation::CustomValidators::uint192HexStringValidator}, - {JS(account), validation::CustomValidators::accountValidator}, - { - "tx_type", - validation::Type{}, - modifiers::ToLower{}, - validation::OneOf( - typesKeysInLowercase.cbegin(), typesKeysInLowercase.cend() - ), - }, - {JS(ledger_hash), validation::CustomValidators::uint256HexStringValidator}, - {JS(ledger_index), validation::CustomValidators::ledgerIndexValidator}, - {JS(ledger_index_min), validation::Type{}}, - {JS(ledger_index_max), validation::Type{}}, - {JS(binary), validation::Type{}}, - {JS(forward), validation::Type{}}, - {JS(limit), - validation::Type{}, - validation::Min(1u), - modifiers::Clamp{kLimitMin, kLimitMax}}, - {JS(marker), - meta::WithCustomError{ - validation::Type{}, - Status{RippledError::RpcInvalidParams, "invalidMarker"} - }, - meta::Section{ - {JS(ledger), validation::Required{}, validation::Type{}}, - {JS(seq), validation::Required{}, validation::Type{}}, - }}, - }; - - return kRpcSpec; - } - - /** - * @brief Process the MPTokenIssuanceHistory command. - * - * @param input The input data for the command. - * @param ctx The context of the request. - * @return The result of the operation. - */ - [[nodiscard]] Result - process(Input const& input, Context const& ctx) const; - -private: - /** - * @brief The inclusive range of ledger sequences a request is restricted to. - */ - struct SequenceRange { - uint32_t min; - uint32_t max; - }; - - /** - * @brief Check that the transaction-history backfill has completed. - * - * The terminal Migrated status is cached, so the backend is only consulted until the backfill - * reports completion. - * - * @param ctx The context of the request. - * @return An empty result if the history is complete; an error otherwise. - */ - [[nodiscard]] MaybeError - verifyHistoryAvailable(Context const& ctx) const; - - /** - * @brief Resolve the ledger sequence range to search from the request's ledger specifiers. - * - * Starts from the server's full ledger range, then narrows it by ledger_index_min / - * ledger_index_max, or collapses it to a single sequence if ledger_hash / ledger_index is - * given. - * - * @param input The input data for the command. - * @param ctx The context of the request. - * @return The resolved range if the specifiers are valid; an error otherwise. - */ - [[nodiscard]] std::expected - resolveSequenceRange(Input const& input, Context const& ctx) const; - - /** - * @brief Fetch one page of transactions for the issuance, optionally restricted to an account. - * - * When the request has no marker, a forward page starts at the lower bound of @p range and a - * reverse page starts after the highest possible transaction at its upper bound. - * - * @param input The input data for the command. - * @param ctx The context of the request. - * @param mptIssuanceID The MPTokenIssuance ID to fetch transactions for. - * @param range The resolved ledger sequence range. - * @return The fetched transactions and the cursor to resume from. - */ - [[nodiscard]] data::TransactionsAndCursor - fetchTransactions( - Input const& input, - Context const& ctx, - xrpl::uint192 const& mptIssuanceID, - SequenceRange range - ) const; - - /** - * @brief Process a fetched transaction page into the response. - * - * Appends transactions that pass the range and type filters. The response marker is taken from - * the fetched page, then cleared if the page runs past the requested range because there is - * nothing left to page through. - * - * @param input The input data for the command. - * @param ctx The context of the request. - * @param range The resolved ledger sequence range. - * @param page The transactions fetched from the database and the cursor to resume from. - * @param [out] response The response to append to. - */ - void - processTransactionsPage( - Input const& input, - Context const& ctx, - SequenceRange range, - data::TransactionsAndCursor const& page, - Output& response - ) const; - - /** - * @brief Convert one transaction to its JSON representation, unless its type is filtered out. - * - * @param txnPlusMeta The transaction and its metadata. - * @param input The input data for the command. - * @param ctx The context of the request. - * @return The JSON representation, or nullopt if the transaction's type does not match the - * requested one. - */ - [[nodiscard]] std::optional - transactionToJsonIfTypeMatches( - data::TransactionAndMetadata const& txnPlusMeta, - Input const& input, - Context const& ctx - ) const; - - /** - * @brief Assemble the API-specific JSON of an expanded transaction and its metadata. - * - * For API v2 and later, the transaction is enriched with ledger information when the - * corresponding ledger header is available. - * - * @param txn The expanded transaction JSON. - * @param meta The expanded metadata JSON. - * @param txnPlusMeta The transaction and its metadata the JSON was expanded from. - * @param ctx The context of the request. - * @return The JSON representation of the transaction. - */ - [[nodiscard]] boost::json::object - expandedTransactionToJson( - boost::json::object txn, - boost::json::object meta, - data::TransactionAndMetadata const& txnPlusMeta, - Context const& ctx - ) const; - - /** - * @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 Marker to a JSON object. - * - * @param [out] jv The JSON object to convert to. - * @param marker The marker to convert. - */ - friend void - tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Marker const& marker); -}; - -} // namespace rpc diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index e2dc42df0..e67d37663 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -128,7 +128,6 @@ target_sources( rpc/handlers/LedgerRangeTests.cpp rpc/handlers/LedgerTests.cpp rpc/handlers/MPTHoldersTests.cpp - rpc/handlers/MPTokenIssuanceHistoryTests.cpp rpc/handlers/NFTBuyOffersTests.cpp rpc/handlers/NFTHistoryTests.cpp rpc/handlers/NFTInfoTests.cpp diff --git a/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp b/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp deleted file mode 100644 index f2ceed962..000000000 --- a/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp +++ /dev/null @@ -1,2176 +0,0 @@ -#include "data/Types.hpp" -#include "migration/MigratiorStatus.hpp" -#include "rpc/Errors.hpp" -#include "rpc/common/AnyHandler.hpp" -#include "rpc/common/Types.hpp" -#include "rpc/handlers/MPTokenIssuanceHistory.hpp" -#include "util/HandlerBaseTestFixture.hpp" -#include "util/LoggerFixtures.hpp" -#include "util/NameGenerator.hpp" -#include "util/TestObject.hpp" - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -using namespace rpc; -using namespace data; -using namespace testing; - -namespace { -constexpr auto kMinSeq = 10; -constexpr auto kMaxSeq = 30; -constexpr auto kAccount = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; -constexpr auto kAccount2 = "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"; -constexpr auto kLedgerHash = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; -constexpr auto kCurrency = "0158415500000000C1F76FF6ECB0BAC600000000"; -// Valid 48-hex MPT issuance ID (from MPTHoldersTests.cpp) -constexpr auto kMptId = "000004C463C52827307480341125DA0577DEFC38405B0E3E"; -constexpr auto kMptIdLowercase = "000004c463c52827307480341125da0577defc38405b0e3e"; -constexpr auto kApiVersion = 2; - -auto const kMigratedStatus = - migration::MigratorStatus{migration::MigratorStatus::Status::Migrated}.toString(); -auto const kNotMigratedStatus = - migration::MigratorStatus{migration::MigratorStatus::Status::NotMigrated}.toString(); - -} // namespace - -struct RPCMPTokenIssuanceHistoryHandlerTest : HandlerBaseTest { - RPCMPTokenIssuanceHistoryHandlerTest() - { - backend_->setRange(kMinSeq, kMaxSeq); - ON_CALL(*backend_, fetchMigratorStatus) - .WillByDefault(Return(std::optional{kMigratedStatus})); - } -}; - -/** - * @brief Enables handler log capture below the test runner's default fatal severity. - */ -struct RPCMPTokenIssuanceHistoryHandlerLogTest : RPCMPTokenIssuanceHistoryHandlerTest, - LoggerFixture {}; - -struct MPTokenIssuanceHistoryParamTestCaseBundle { - std::string testName; - std::string testJson; - std::string expectedError; - std::string expectedErrorMessage; -}; - -struct MPTokenIssuanceHistoryParameterTest - : public RPCMPTokenIssuanceHistoryHandlerTest, - public WithParamInterface {}; - -static auto -generateTestValuesForParametersTest() -{ - return std::vector{ - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "MissingMptIssuanceID", - .testJson = R"JSON({})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Required field 'mpt_issuance_id' missing" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "MalformedMptIssuanceID", - .testJson = R"JSON({"mpt_issuance_id": "NOTAHEXSTRING"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "mpt_issuance_idMalformed" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "BinaryNotBool", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "binary": 1})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "ForwardNotBool", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "forward": 1})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LedgerIndexMinNotInt", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index_min": "x"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LedgerIndexMaxNotInt", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index_max": "x"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "BadAccount", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "account": "not_a_valid_account"})JSON", - .expectedError = "actMalformed", - .expectedErrorMessage = "accountMalformed" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "UnknownTxType", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "tx_type": "NotARealType"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid field 'tx_type'." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "MarkerNotObject", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "marker": 101})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "invalidMarker" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "MarkerMissingSeq", - .testJson = R"JSON({ - "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", - "marker": {"ledger": 123} - })JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Required field 'seq' missing" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "MarkerMissingLedger", - .testJson = R"JSON({ - "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", - "marker": {"seq": 123} - })JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Required field 'ledger' missing" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LedgerIndexInvalid", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_index": "x"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "ledgerIndexMalformed" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LedgerHashInvalid", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_hash": "x"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "ledger_hashMalformed" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LedgerHashNotString", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "ledger_hash": 123})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "ledger_hashNotString" - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LimitNotInt", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": "123"})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LimitNegative", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": -1})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - MPTokenIssuanceHistoryParamTestCaseBundle{ - .testName = "LimitZero", - .testJson = - R"JSON({"mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", "limit": 0})JSON", - .expectedError = "invalidParams", - .expectedErrorMessage = "Invalid parameters." - }, - }; -} - -INSTANTIATE_TEST_CASE_P( - RPCMPTokenIssuanceHistoryGroup1, - MPTokenIssuanceHistoryParameterTest, - ValuesIn(generateTestValuesForParametersTest()), - tests::util::kNameGenerator -); - -TEST_P(MPTokenIssuanceHistoryParameterTest, InvalidParams) -{ - auto const testBundle = GetParam(); - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::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(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMinOutOfRange) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": 9 - }})JSON", - kMptId - ) - ); - 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(), "lgrIdxMalformed"); - EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMinOutOfRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMaxOutOfRange) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_max": 31 - }})JSON", - kMptId - ) - ); - 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(), "lgrIdxMalformed"); - EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMaxOutOfRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, InvertedLedgerRange) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": 20, - "ledger_index_max": 11 - }})JSON", - kMptId - ) - ); - 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(), "lgrIdxsInvalid"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ContainsLedgerSpecifierAndRange) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": 11, - "ledger_index_max": 20, - "ledger_index": 10 - }})JSON", - kMptId - ) - ); - 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(), "invalidParams"); - EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateNotMigratedReturnsNotReady) -{ - ON_CALL(*backend_, fetchMigratorStatus) - .WillByDefault(Return(std::optional{kNotMigratedStatus})); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - 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(), "notReady"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateMissingStatusReturnsNotReady) -{ - ON_CALL(*backend_, fetchMigratorStatus).WillByDefault(Return(std::nullopt)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - 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(), "notReady"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateUnknownStatusStringReturnsNotReady) -{ - ON_CALL(*backend_, fetchMigratorStatus) - .WillByDefault(Return(std::optional{"NotAStatus"})); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - 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(), "notReady"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateNotReadyErrorMessageContent) -{ - ON_CALL(*backend_, fetchMigratorStatus) - .WillByDefault(Return(std::optional{kNotMigratedStatus})); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - 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(), "notReady"); - auto const msg = err.at("error_message").as_string(); - EXPECT_TRUE(msg.find("backfill has not completed") != std::string::npos); - // The remedy is the operator's, not the caller's: no CLI invocation in a client-facing - // error. - EXPECT_TRUE(msg.find("clio_server") == std::string::npos); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateMigratedServesRequest) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_TRUE(output.result->as_object().contains("mpt_issuance_id")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateCachedMigratedShortCircuit) -{ - EXPECT_CALL(*backend_, fetchMigratorStatus(MPTokenIssuanceHistoryHandler::kMigratorName, _)) - .Times(1) - .WillOnce(Return(std::optional{kMigratedStatus})); - - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - // Same handler instance across both calls: the cached Migrated flag skips the second check. - auto anyHandler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - - runSpawn([&](auto yield) { - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output1 = anyHandler.process(req, Context{yield}); - ASSERT_TRUE(output1); - auto const output2 = anyHandler.process(req, Context{yield}); - ASSERT_TRUE(output2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, GateCachedMigratedSurvivesHandlerCopy) -{ - EXPECT_CALL(*backend_, fetchMigratorStatus(MPTokenIssuanceHistoryHandler::kMigratorName, _)) - .Times(1) - .WillOnce(Return(std::optional{kMigratedStatus})); - - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - // Every request gets a copy of the registered handler, so the cached flag survives the copy. - auto original = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const copy = original; - - runSpawn([&](auto yield) { - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = original.process(req, Context{yield}); - ASSERT_TRUE(output); - auto const outputFromCopy = copy.process(req, Context{yield}); - ASSERT_TRUE(outputFromCopy); - }); -} - -static std::vector -genTransactions(uint32_t seq1, uint32_t seq2) -{ - auto transactions = std::vector{}; - - auto trans1 = TransactionAndMetadata(); - xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans1.transaction = obj1.getSerializer().peekData(); - trans1.ledgerSequence = seq1; - xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans1.metadata = meta1.getSerializer().peekData(); - trans1.date = 1; - transactions.push_back(trans1); - - auto trans2 = TransactionAndMetadata(); - xrpl::STObject const obj2 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans2.transaction = obj2.getSerializer().peekData(); - trans2.ledgerSequence = seq2; - xrpl::STObject const meta2 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans2.metadata = meta2.getSerializer().peekData(); - trans2.date = 2; - transactions.push_back(trans2); - - return transactions; -} - -// Build a mixed-type page: one Payment (at seqPayment) and one OfferCreate (at seqOffer). -static std::vector -genMixedTypeTransactions(uint32_t seqPayment, uint32_t seqOffer) -{ - auto transactions = std::vector{}; - - auto payment = TransactionAndMetadata(); - xrpl::STObject const paymentObj = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - payment.transaction = paymentObj.getSerializer().peekData(); - payment.ledgerSequence = seqPayment; - xrpl::STObject const paymentMeta = - createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - payment.metadata = paymentMeta.getSerializer().peekData(); - payment.date = 1; - transactions.push_back(payment); - - auto offer = TransactionAndMetadata(); - xrpl::STObject const offerObj = - createCreateOfferTransactionObject(kAccount, 2, 100, kCurrency, kAccount2, 200, 300); - offer.transaction = offerObj.getSerializer().peekData(); - offer.ledgerSequence = seqOffer; - xrpl::STObject const offerMeta = - createMetaDataForCreateOffer(kCurrency, kAccount, 100, 200, 300); - offer.metadata = offerMeta.getSerializer().peekData(); - offer.date = 2; - transactions.push_back(offer); - - return transactions; -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, RoutingWithoutAccountCallsFetchMPTIssuanceTxns) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(1); - EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(0); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, RoutingWithAccountCallsFetchAccountMPTIssuanceTxns) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(1); - EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(0); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format(R"JSON({{"mpt_issuance_id": "{}", "account": "{}"}})JSON", kMptId, kAccount) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardCursorSeedFromMinIndex) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 1, 0})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": true - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq + 1); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ReverseCursorSeedFromMaxIndex) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, NoIndexSpecifiedForwardSeedsFromGlobalMin) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq, 0})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "forward": true - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, NoIndexSpecifiedReverseSeedsFromGlobalMax) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "forward": false - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, IndexSpecificForwardFalseV1) -{ - constexpr auto kOutput = R"JSON({ - "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", - "ledger_index_min": 11, - "ledger_index_max": 29, - "transactions": [ - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Balance": "22" - }, - "LedgerEntryType": "AccountRoot" - } - }, - { - "ModifiedNode": { - "FinalFields": { - "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Balance": "23" - }, - "LedgerEntryType": "AccountRoot" - } - } - ], - "TransactionIndex": 0, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "unavailable" - }, - "tx": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Amount": "1", - "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Fee": "1", - "Sequence": 32, - "SigningPubKey": "74657374", - "TransactionType": "Payment", - "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", - "DeliverMax": "1", - "ledger_index": 11, - "date": 1 - }, - "validated": true - }, - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Balance": "22" - }, - "LedgerEntryType": "AccountRoot" - } - }, - { - "ModifiedNode": { - "FinalFields": { - "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Balance": "23" - }, - "LedgerEntryType": "AccountRoot" - } - } - ], - "TransactionIndex": 0, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "unavailable" - }, - "tx": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Amount": "1", - "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Fee": "1", - "Sequence": 32, - "SigningPubKey": "74657374", - "TransactionType": "Payment", - "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", - "DeliverMax": "1", - "ledger_index": 29, - "date": 2 - }, - "validated": true - } - ], - "validated": true, - "marker": { - "ledger": 12, - "seq": 34 - } - })JSON"; - - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result.value(), boost::json::parse(kOutput)); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, IndexSpecificForwardFalseV2) -{ - constexpr auto kOutput = R"JSON({ - "mpt_issuance_id": "000004C463C52827307480341125DA0577DEFC38405B0E3E", - "ledger_index_min": 11, - "ledger_index_max": 29, - "transactions": [ - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Balance": "22" - }, - "LedgerEntryType": "AccountRoot" - } - }, - { - "ModifiedNode": { - "FinalFields": { - "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Balance": "23" - }, - "LedgerEntryType": "AccountRoot" - } - } - ], - "TransactionIndex": 0, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "unavailable" - }, - "tx_json": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Fee": "1", - "Sequence": 32, - "SigningPubKey": "74657374", - "TransactionType": "Payment", - "DeliverMax": "1", - "ledger_index": 11, - "date": 1 - }, - "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", - "ledger_index": 11, - "close_time_iso": "2000-01-01T00:00:00Z", - "ledger_hash": "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", - "validated": true - }, - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Balance": "22" - }, - "LedgerEntryType": "AccountRoot" - } - }, - { - "ModifiedNode": { - "FinalFields": { - "Account": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Balance": "23" - }, - "LedgerEntryType": "AccountRoot" - } - } - ], - "TransactionIndex": 0, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "unavailable" - }, - "tx_json": { - "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", - "Destination": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun", - "Fee": "1", - "Sequence": 32, - "SigningPubKey": "74657374", - "TransactionType": "Payment", - "DeliverMax": "1", - "ledger_index": 29, - "date": 2 - }, - "hash": "51D2AAA6B8E4E16EF22F6424854283D8391B56875858A711B8CE4D5B9A422CC2", - "ledger_index": 29, - "close_time_iso": "2000-01-01T00:00:00Z", - "ledger_hash": "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652", - "validated": true - } - ], - "validated": true, - "marker": { - "ledger": 12, - "seq": 34 - } - })JSON"; - - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .WillOnce(Return(transCursor)); - - auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq); - ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(ledgerHeader)); - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(2); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 1 - ) - ); - auto const output = - handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result.value(), boost::json::parse(kOutput)); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryTrueV1) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "binary": true - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - auto const& firstTx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_TRUE(firstTx.contains("tx_blob")); - EXPECT_TRUE(firstTx.contains("meta")); - EXPECT_TRUE(firstTx.contains("ledger_index")); - EXPECT_TRUE(firstTx.contains("date")); - EXPECT_TRUE(firstTx.contains("validated")); - EXPECT_FALSE(output.result->as_object().contains("limit")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryTrueV2) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, false, testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq, INT32_MAX})), _ - ) - ) - .WillOnce(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "binary": true - }})JSON", - kMptId - ) - ); - auto const output = - handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - auto const& firstTx = output.result->at("transactions").as_array()[0].as_object(); - // V2 binary uses meta_blob instead of meta - EXPECT_TRUE(firstTx.contains("tx_blob")); - EXPECT_TRUE(firstTx.contains("meta_blob")); - EXPECT_TRUE(firstTx.contains("ledger_index")); - EXPECT_TRUE(firstTx.contains("date")); - EXPECT_TRUE(firstTx.contains("validated")); - EXPECT_EQ( - firstTx.at("meta_blob").as_string(), - "201C00000000F8E5110061E762400000000000001681144B4E9C06F24296074F7B" - "C48F92A97916C6DC5EA9E1E1E5110061E76240000000000000178114D31252CF90" - "2EF8DD8451243869B38667CBD89DF3E1E1F1031000" - ); - EXPECT_EQ( - firstTx.at("tx_blob").as_string(), - "120000240000002061400000000000000168400000000000000173047465737481" - "144B4E9C06F24296074F7BC48F92A97916C6DC5EA98314D31252CF902EF8DD8451" - "243869B38667CBD89DF3" - ); - EXPECT_FALSE(output.result->as_object().contains("limit")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitAndMarkerRoundTrip) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, false, testing::Optional(testing::Eq(TransactionsCursor{10, 11})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "limit": 2, - "forward": false, - "marker": {{"ledger": 10, "seq": 11}} - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_EQ(output.result->at("limit").as_uint64(), 2); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitMoreThanMax) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false, - "limit": {} - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 1, - MPTokenIssuanceHistoryHandler::kLimitMax + 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("limit").as_uint64(), MPTokenIssuanceHistoryHandler::kLimitMax); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitNotSetDefaultUsedAndNotInResponse) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_FALSE(output.result->as_object().contains("limit")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxBelowMinSeqClipped) -{ - // Reverse: first tx at kMaxSeq-1 is in range, second at kMinSeq+1 is below minIndex. - auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false - }})JSON", - kMptId, - kMinSeq + 2, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxAboveMaxSeqClipped) -{ - // Reverse: first tx at kMaxSeq-1 is above maxIndex, second at kMinSeq+1 is in range. - auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 2, INT32_MAX})), - _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": false - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 2 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificLedgerIndex) -{ - // reverse traversal; first tx at kMaxSeq-1 (= ledger_index), second below => dropped - auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .Times(1); - - auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); - ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(ledgerHeader)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index": {} - }})JSON", - kMptId, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - EXPECT_FALSE(output.result->as_object().contains("limit")); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificNonExistLedgerIndex) -{ - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); - ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(std::nullopt)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index": {} - }})JSON", - kMptId, - kMaxSeq - 1 - ) - ); - 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(), "lgrNotFound"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, SpecificLedgerHash) -{ - auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, - _, - false, - testing::Optional(testing::Eq(TransactionsCursor{kMaxSeq - 1, INT32_MAX})), - _ - ) - ) - .Times(1); - - auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); - EXPECT_CALL(*backend_, fetchLedgerByHash).Times(1); - ON_CALL(*backend_, fetchLedgerByHash(xrpl::uint256{kLedgerHash}, _)) - .WillByDefault(Return(ledgerHeader)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_hash": "{}" - }})JSON", - kMptId, - kLedgerHash - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - EXPECT_FALSE(output.result->as_object().contains("limit")); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, EmptyResultForUnseenId) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_TRUE(output.result->at("transactions").as_array().empty()); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, EmptyResultForUnseenIdWithAccount) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format(R"JSON({{"mpt_issuance_id": "{}", "account": "{}"}})JSON", kMptId, kAccount) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - EXPECT_TRUE(output.result->at("transactions").as_array().empty()); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingBlobMidPageSkipped) -{ - auto transactions = std::vector{}; - - auto trans1 = TransactionAndMetadata(); - xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans1.transaction = obj1.getSerializer().peekData(); - trans1.ledgerSequence = kMinSeq + 1; - xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans1.metadata = meta1.getSerializer().peekData(); - trans1.date = 1; - transactions.push_back(trans1); - - auto emptyTrans = TransactionAndMetadata(); - emptyTrans.ledgerSequence = kMinSeq + 2; - emptyTrans.date = 2; - transactions.push_back(emptyTrans); - - auto trans3 = TransactionAndMetadata(); - xrpl::STObject const obj3 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans3.transaction = obj3.getSerializer().peekData(); - trans3.ledgerSequence = kMaxSeq - 1; - xrpl::STObject const meta3 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans3.metadata = meta3.getSerializer().peekData(); - trans3.date = 3; - transactions.push_back(trans3); - - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": -1, - "ledger_index_max": -1, - "forward": false - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingBlobInBinaryModeSkipped) -{ - // Page of [valid, empty, valid]: the empty record is skipped, marker unaffected. - auto transactions = std::vector{}; - - auto trans1 = TransactionAndMetadata(); - xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans1.transaction = obj1.getSerializer().peekData(); - trans1.ledgerSequence = kMaxSeq - 1; - xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans1.metadata = meta1.getSerializer().peekData(); - trans1.date = 1; - transactions.push_back(trans1); - - auto emptyTrans = TransactionAndMetadata(); - emptyTrans.ledgerSequence = kMinSeq + 2; - emptyTrans.date = 2; - transactions.push_back(emptyTrans); - - auto trans3 = TransactionAndMetadata(); - xrpl::STObject const obj3 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans3.transaction = obj3.getSerializer().peekData(); - trans3.ledgerSequence = kMinSeq + 1; - xrpl::STObject const meta3 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans3.metadata = meta3.getSerializer().peekData(); - trans3.date = 3; - transactions.push_back(trans3); - - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{5, 6}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "binary": true - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - for (auto const& tx : output.result->at("transactions").as_array()) - EXPECT_TRUE(tx.as_object().contains("tx_blob")); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 5, "seq": 6})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterSparseMixedPageKeepsMarker) -{ - // Filtering 1 of 2 yields a sparse page, but the marker rides the raw page boundary. - auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(0); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "tx_type": "Payment" - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); - auto const& tx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_EQ(tx.at("tx").as_object().at("TransactionType").as_string(), "Payment"); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterNonMatchReturnsEmpty) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "tx_type": "OfferCreate" - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 0); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterCaseInsensitive) -{ - // ToLower modifier means mixed-case "pAyMeNt" still matches. - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "tx_type": "pAyMeNt" - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterSparseMixedPageWithAccountKeepsMarker) -{ - // As the non-account sparse-page test, but via the account routing path. - auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - EXPECT_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).Times(1); - EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).Times(0); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "account": "{}", - "tx_type": "Payment" - }})JSON", - kMptId, - kAccount - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); - auto const& tx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_EQ(tx.at("tx").as_object().at("TransactionType").as_string(), "Payment"); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, TxTypeFilterWithAccountNonMatchReturnsEmpty) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchAccountMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "account": "{}", - "tx_type": "OfferCreate" - }})JSON", - kMptId, - kAccount - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 0); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardTxAboveMaxSeqClippedAndMarkerCleared) -{ - // Forward: second tx exceeds maxIndex, so it is dropped and the marker cleared. - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 1, 0})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": true - }})JSON", - kMptId, - kMinSeq + 1, - kMaxSeq - 2 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryWithTxTypeFilterV1) -{ - // binary + tx_type: expand to filter by type, then emit the survivors in binary form. - auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "binary": true, - "tx_type": "Payment" - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); - auto const& tx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_TRUE(tx.contains("tx_blob")); - EXPECT_TRUE(tx.contains("meta")); - EXPECT_TRUE(tx.contains("ledger_index")); - EXPECT_TRUE(tx.contains("date")); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, BinaryWithTxTypeFilterV2) -{ - // As above, but V2 binary uses meta_blob. - auto const transactions = genMixedTypeTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "binary": true, - "tx_type": "Payment" - }})JSON", - kMptId - ) - ); - auto const output = - handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); - auto const& tx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_TRUE(tx.contains("tx_blob")); - EXPECT_TRUE(tx.contains("meta_blob")); - EXPECT_TRUE(tx.contains("ledger_index")); - EXPECT_TRUE(tx.contains("date")); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ResponseAlwaysHasMandatoryFields) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - auto const& obj = output.result->as_object(); - EXPECT_TRUE(obj.contains("mpt_issuance_id")); - EXPECT_TRUE(obj.contains("ledger_index_min")); - EXPECT_TRUE(obj.contains("ledger_index_max")); - EXPECT_TRUE(obj.contains("transactions")); - EXPECT_TRUE(obj.contains("validated")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMinAboveRangeMax) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {} - }})JSON", - kMptId, - kMaxSeq + 1 - ) - ); - 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(), "lgrIdxMalformed"); - EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMinOutOfRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexMaxBelowRangeMin) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_max": {} - }})JSON", - kMptId, - kMinSeq - 1 - ) - ); - 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(), "lgrIdxMalformed"); - EXPECT_EQ(err.at("error_message").as_string(), "ledgerSeqMaxOutOfRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerHashWithOnlyLedgerIndexMin) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_hash": "{}", - "ledger_index_min": {} - }})JSON", - kMptId, - kLedgerHash, - kMinSeq + 1 - ) - ); - 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(), "invalidParams"); - EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerHashWithOnlyLedgerIndexMax) -{ - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_hash": "{}", - "ledger_index_max": {} - }})JSON", - kMptId, - kLedgerHash, - kMaxSeq - 1 - ) - ); - 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(), "invalidParams"); - EXPECT_EQ(err.at("error_message").as_string(), "containsLedgerSpecifierAndRange"); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexValidatedStringIsNotASpecifier) -{ - // "validated" resolves to no concrete index, so the request keeps the full ledger range. - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(0); - EXPECT_CALL(*backend_, fetchLedgerByHash).Times(0); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index": "validated" - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMinSeq); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LedgerIndexNumericStringAccepted) -{ - auto const transactions = genTransactions(kMaxSeq - 1, kMinSeq + 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - auto const ledgerHeader = createLedgerHeader(kLedgerHash, kMaxSeq - 1); - ON_CALL(*backend_, fetchLedgerBySequence(kMaxSeq - 1, _)).WillByDefault(Return(ledgerHeader)); - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index": "{}" - }})JSON", - kMptId, - kMaxSeq - 1 - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("ledger_index_min").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("ledger_index_max").as_uint64(), kMaxSeq - 1); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MissingMetadataOnlySkipped) -{ - // Page of [no metadata, valid]: the incomplete record is skipped, marker unaffected. - auto transactions = std::vector{}; - - auto noMeta = TransactionAndMetadata(); - xrpl::STObject const noMetaObj = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - noMeta.transaction = noMetaObj.getSerializer().peekData(); - noMeta.ledgerSequence = kMaxSeq - 1; - noMeta.date = 1; - transactions.push_back(noMeta); - - auto complete = TransactionAndMetadata(); - xrpl::STObject const completeObj = - createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - complete.transaction = completeObj.getSerializer().peekData(); - complete.ledgerSequence = kMinSeq + 1; - xrpl::STObject const completeMeta = - createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - complete.metadata = completeMeta.getSerializer().peekData(); - complete.date = 2; - transactions.push_back(complete); - - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 1); - auto const& tx = output.result->at("transactions").as_array()[0].as_object(); - EXPECT_EQ(tx.at("tx").as_object().at("ledger_index").as_uint64(), kMinSeq + 1); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, V2MissingLedgerHeaderOmitsCloseTimeAndLedgerHash) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - ON_CALL(*backend_, fetchLedgerBySequence).WillByDefault(Return(std::nullopt)); - EXPECT_CALL(*backend_, fetchLedgerBySequence).Times(2); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = - handler.process(req, Context{.yield = yield, .apiVersion = kApiVersion}); - ASSERT_TRUE(output); - ASSERT_EQ(output.result->at("transactions").as_array().size(), 2); - for (auto const& tx : output.result->at("transactions").as_array()) { - auto const& obj = tx.as_object(); - EXPECT_FALSE(obj.contains("close_time_iso")); - EXPECT_FALSE(obj.contains("ledger_hash")); - EXPECT_TRUE(obj.contains("hash")); - EXPECT_TRUE(obj.contains("tx_json")); - EXPECT_TRUE(obj.contains("ledger_index")); - EXPECT_TRUE(obj.at("validated").as_bool()); - } - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, ForwardTxBelowMinSeqNotClipped) -{ - // Forward traversal only clips above maxIndex; one below minIndex is kept, as account_tx does. - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, true, testing::Optional(testing::Eq(TransactionsCursor{kMinSeq + 2, 0})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "ledger_index_min": {}, - "ledger_index_max": {}, - "forward": true - }})JSON", - kMptId, - kMinSeq + 2, - kMaxSeq - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 2); - EXPECT_EQ( - output.result->at("marker").as_object(), - boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") - ); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MarkerSeedsCursorInForwardMode) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, _, true, testing::Optional(testing::Eq(TransactionsCursor{21, 22})), _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "forward": true, - "marker": {{"ledger": 21, "seq": 22}} - }})JSON", - kMptId - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_FALSE(output.result->as_object().contains("marker")); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, LimitAtMinIsPassedThrough) -{ - auto const transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); - auto const transCursor = - TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, static_cast(MPTokenIssuanceHistoryHandler::kLimitMin), _, _, _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format( - R"JSON({{ - "mpt_issuance_id": "{}", - "limit": {} - }})JSON", - kMptId, - MPTokenIssuanceHistoryHandler::kLimitMin - ) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("limit").as_uint64(), MPTokenIssuanceHistoryHandler::kLimitMin); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, DefaultLimitPassedToBackend) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - EXPECT_CALL( - *backend_, - fetchMPTokenIssuanceTransactions( - _, static_cast(MPTokenIssuanceHistoryHandler::kLimitDefault), _, _, _ - ) - ) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerTest, MptIssuanceIDIsNormalizedInOutput) -{ - auto const transCursor = TransactionsAndCursor{.txns = {}, .cursor = std::nullopt}; - ON_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillByDefault(Return(transCursor)); - - EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions(xrpl::uint192{kMptId}, _, _, _, _)) - .Times(1); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = boost::json::parse( - fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptIdLowercase) - ); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("mpt_issuance_id").as_string(), kMptId); - }); -} - -TEST_F(RPCMPTokenIssuanceHistoryHandlerLogTest, LogsFetchDurationAndSkippedIndexEntry) -{ - auto transactions = std::vector{}; - - auto trans1 = TransactionAndMetadata(); - xrpl::STObject const obj1 = createPaymentTransactionObject(kAccount, kAccount2, 1, 1, 32); - trans1.transaction = obj1.getSerializer().peekData(); - trans1.ledgerSequence = kMinSeq + 1; - xrpl::STObject const meta1 = createPaymentTransactionMetaObject(kAccount, kAccount2, 22, 23); - trans1.metadata = meta1.getSerializer().peekData(); - trans1.date = 1; - transactions.push_back(trans1); - - auto emptyTrans = TransactionAndMetadata(); - emptyTrans.ledgerSequence = kMinSeq + 2; - emptyTrans.date = 2; - transactions.push_back(emptyTrans); - - auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; - EXPECT_CALL(*backend_, fetchMPTokenIssuanceTransactions).WillOnce(Return(transCursor)); - - runSpawn([&, this](auto yield) { - auto const handler = AnyHandler{MPTokenIssuanceHistoryHandler{backend_}}; - auto const req = - boost::json::parse(fmt::format(R"JSON({{"mpt_issuance_id": "{}"}})JSON", kMptId)); - auto const output = handler.process(req, Context{yield}); - ASSERT_TRUE(output); - EXPECT_EQ(output.result->at("transactions").as_array().size(), 1); - }); - - auto const logs = getLoggerString(); - EXPECT_THAT( - logs, ContainsRegex(R"(inf:RPC - db fetch took [0-9]+ milliseconds - num blobs = 2)") - ); - EXPECT_THAT( - logs, - HasSubstr( - fmt::format( - "war:RPC - Skipping index entry with no matching transaction record; " - "mpt_issuance_id = {}", - kMptId - ) - ) - ); -} From 4f884e3b4ba21d21a9e3d57191ff3a3e95fa367c Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Thu, 20 Aug 2026 15:57:46 +0100 Subject: [PATCH 3/4] fix: Better validator for issuer (#3184) --- src/rpc/common/Validators.cpp | 2 +- tests/unit/rpc/handlers/LedgerEntryTests.cpp | 21 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rpc/common/Validators.cpp b/src/rpc/common/Validators.cpp index 9be9275a3..bfa1a64bf 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -363,7 +363,7 @@ CustomValidator CustomValidators::authorizeCredentialValidator = } // don't want to change issuer error message to be about credentials - if (!issuerValidator.verify(credObj, "issuer")) { + if (!accountBase58Validator.verify(credObj, "issuer")) { return Error{ Status{ClioError::RpcMalformedAuthorizedCredentials, "issuer NotString"} }; diff --git a/tests/unit/rpc/handlers/LedgerEntryTests.cpp b/tests/unit/rpc/handlers/LedgerEntryTests.cpp index 5a7e66c4f..bece6a52a 100644 --- a/tests/unit/rpc/handlers/LedgerEntryTests.cpp +++ b/tests/unit/rpc/handlers/LedgerEntryTests.cpp @@ -347,6 +347,27 @@ generateTestValuesForParametersTest() .expectedErrorMessage = "issuer NotString" }, + ParamTestCaseBundle{ + .testName = "DepositPreauthAuthorizeCredentialsHexIssuer", + .testJson = fmt::format( + R"JSON({{ + "deposit_preauth": {{ + "owner": "{}", + "authorized_credentials": [ + {{ + "issuer": "0000000000000000000000000000000000000002", + "credential_type": "{}" + }} + ] + }} + }})JSON", + kAccount, + kCredentialType + ), + .expectedError = "malformedAuthorizedCredentials", + .expectedErrorMessage = "issuer NotString" + }, + ParamTestCaseBundle{ .testName = "DepositPreauthAuthorizeCredentialsIncorrectCredentialType", .testJson = fmt::format( From e6c91a15721b4886a2b0c5e228cbbb17ee87b182 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 15:57:56 +0100 Subject: [PATCH 4/4] fix: Use credentialTypeValidator in LedgerEntry (#3185) --- src/rpc/common/Validators.hpp | 3 ++- src/rpc/handlers/LedgerEntry.cpp | 17 +++++++++++------ src/rpc/handlers/LedgerEntry.hpp | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/rpc/common/Validators.hpp b/src/rpc/common/Validators.hpp index 5c5cccbc3..ee9527b51 100644 --- a/src/rpc/common/Validators.hpp +++ b/src/rpc/common/Validators.hpp @@ -590,7 +590,8 @@ struct CustomValidators final { /** * @brief Provides a validator for validating credential_type. * - * Used by AuthorizeCredentialValidator in deposit_preauth. + * Used by AuthorizeCredentialValidator in deposit_preauth and by the credential + * object lookup in ledger_entry. */ static CustomValidator credentialTypeValidator; }; diff --git a/src/rpc/handlers/LedgerEntry.cpp b/src/rpc/handlers/LedgerEntry.cpp index 20982b628..bef9c8666 100644 --- a/src/rpc/handlers/LedgerEntry.cpp +++ b/src/rpc/handlers/LedgerEntry.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include +#include #include #include #include @@ -422,20 +424,23 @@ tag_invoke(boost::json::value_to_tag, boost::json::va return xrpl::keylet::oracle(*account, documentId).key; }; - auto const parseCredentialFromJson = [](boost::json::value const& json) { + auto const parseCredentialFromJson = + [](boost::json::value const& json) -> std::optional { auto const subject = util::parseBase58Wrapper( boost::json::value_to(json.at(JS(subject))) ); auto const issuer = util::parseBase58Wrapper( boost::json::value_to(json.at(JS(issuer))) ); - auto const credType = + auto const credTypeOpt = xrpl::strUnHex(boost::json::value_to(json.at(JS(credential_type)))); - return xrpl::keylet::credential( - *subject, *issuer, xrpl::Slice(credType->data(), credType->size()) - ) - .key; + return credTypeOpt.transform([&](xrpl::Blob const& credType) -> xrpl::uint256 { + return xrpl::keylet::credential( + *subject, *issuer, xrpl::Slice(credType.data(), credType.size()) + ) + .key; + }); }; auto const indexFieldType = diff --git a/src/rpc/handlers/LedgerEntry.hpp b/src/rpc/handlers/LedgerEntry.hpp index 8c5b0702e..ea32d9450 100644 --- a/src/rpc/handlers/LedgerEntry.hpp +++ b/src/rpc/handlers/LedgerEntry.hpp @@ -380,6 +380,7 @@ public: meta::WithCustomError{ validation::Type{}, Status(ClioError::RpcMalformedRequest) }, + validation::CustomValidators::credentialTypeValidator, }, }}}, {JS(mpt_issuance),