From e8e0478333c86ccc0e1d526f0cd2a2f19b45479c Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 18 Aug 2026 09:43:06 -0700 Subject: [PATCH] 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 + ) + ) + ); +}