diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 9691de44d2..6dd613ea73 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 @@ -58,4 +59,4 @@ target_sources( handlers/VaultInfo.cpp ) -target_link_libraries(clio_rpc PUBLIC clio_util clio_data) +target_link_libraries(clio_rpc PUBLIC clio_util clio_data clio_migration) diff --git a/src/rpc/RPCCenter.cpp b/src/rpc/RPCCenter.cpp index 8280ded91c..22fa9d8b97 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 dae6121f24..c199753c8d 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 0000000000..7a06acba80 --- /dev/null +++ b/src/rpc/handlers/MPTokenIssuanceHistory.cpp @@ -0,0 +1,310 @@ +#include "rpc/handlers/MPTokenIssuanceHistory.hpp" + +#include "data/Types.hpp" +#include "migration/MigratiorStatus.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 + +namespace rpc { + +MPTokenIssuanceHistoryHandler::Result +MPTokenIssuanceHistoryHandler::process( + MPTokenIssuanceHistoryHandler::Input const& input, + Context const& ctx +) const +{ + // Fail closed unless the backfill is done: partial history must never be served. + if (not migrated_->load(std::memory_order_relaxed)) { + auto const statusString = sharedPtrBackend_->fetchMigratorStatus(kMigratorName, ctx.yield); + if (statusString.has_value() and + migration::MigratorStatus::fromString(*statusString) == + migration::MigratorStatus::Status::Migrated) { + migrated_->store(true, std::memory_order_relaxed); + } else { + return Error{Status{ + RippledError::RpcNotReady, + "mptoken_issuance_history is unavailable until the MPT transaction-history " + "backfill " + "completes on this node. Run: ./clio_server --migrate " + "MPTTransactionHistoryMigrator " + "CONFIG" + }}; + } + } + + auto const range = sharedPtrBackend_->fetchLedgerRange(); + ASSERT(range.has_value(), "MPTokenIssuanceHistory's ledger range must be available"); + + auto [minIndex, maxIndex] = *range; // NOLINT(bugprone-unchecked-optional-access) + + if (input.ledgerIndexMin) { + // NOLINTBEGIN(bugprone-unchecked-optional-access) + if (range->maxSequence < input.ledgerIndexMin || range->minSequence > input.ledgerIndexMin) + return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMinOutOfRange"}}; + // NOLINTEND(bugprone-unchecked-optional-access) + + minIndex = *input.ledgerIndexMin; + } + + if (input.ledgerIndexMax) { + // NOLINTBEGIN(bugprone-unchecked-optional-access) + if (range->maxSequence < input.ledgerIndexMax || range->minSequence > input.ledgerIndexMax) + return Error{Status{RippledError::RpcLgrIdxMalformed, "ledgerSeqMaxOutOfRange"}}; + // NOLINTEND(bugprone-unchecked-optional-access) + + maxIndex = *input.ledgerIndexMax; + } + + if (minIndex > maxIndex) + return Error{Status{RippledError::RpcLgrIdxsInvalid}}; + + if (input.ledgerHash || input.ledgerIndex) { + // rippled does not have this check + if (input.ledgerIndexMax || input.ledgerIndexMin) { + return Error{Status{RippledError::RpcInvalidParams, "containsLedgerSpecifierAndRange"}}; + } + + auto const expectedLgrInfo = getLedgerHeaderFromHashOrSeq( + *sharedPtrBackend_, + ctx.yield, + input.ledgerHash, + input.ledgerIndex, + range->maxSequence // NOLINT(bugprone-unchecked-optional-access) + ); + + if (not expectedLgrInfo.has_value()) + return Error{expectedLgrInfo.error()}; + + maxIndex = minIndex = expectedLgrInfo->seq; + } + + std::optional cursor; + + // if marker exists + if (input.marker) { + cursor = {input.marker->ledger, input.marker->seq}; + } else { + if (input.forward) { + cursor = {minIndex, 0}; + } else { + cursor = {maxIndex, std::numeric_limits::max()}; + } + } + + auto const limit = input.limit.value_or(kLimitDefault); + auto const mptIssuanceID = xrpl::uint192{input.mptIssuanceID.c_str()}; + + // tx_type is applied post-fetch below, as account_tx does. + auto const [txnsAndCursor, timeDiff] = util::timed([&]() -> data::TransactionsAndCursor { + if (input.account) { + auto const account = accountFromStringStrict(*input.account); + ASSERT(account.has_value(), "Account must be decodable after spec validation"); + return sharedPtrBackend_->fetchAccountMPTokenIssuanceTransactions( + mptIssuanceID, *account, limit, input.forward, cursor, ctx.yield + ); + } + return sharedPtrBackend_->fetchMPTokenIssuanceTransactions( + mptIssuanceID, limit, input.forward, cursor, ctx.yield + ); + }); + LOG(log_.info()) << "db fetch took " << timeDiff + << " milliseconds - num blobs = " << txnsAndCursor.txns.size(); + + Output response; + auto const [blobs, retCursor] = txnsAndCursor; + + if (retCursor) + response.marker = {.ledger = retCursor->ledgerSequence, .seq = retCursor->transactionIndex}; + + for (auto const& txnPlusMeta : blobs) { + // 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; + } + + // over the range + if ((txnPlusMeta.ledgerSequence < minIndex && !input.forward) || + (txnPlusMeta.ledgerSequence > maxIndex && input.forward)) { + response.marker = std::nullopt; + break; + } + if (txnPlusMeta.ledgerSequence > maxIndex && !input.forward) { + LOG(log_.debug()) << "Skipping over transactions from incomplete ledger"; + continue; + } + + boost::json::object obj; + + // tx_type needs the expanded form to read TransactionType, even when binary is set + 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) + continue; + + if (!input.binary) { + auto const txKey = ctx.apiVersion > 1u ? JS(tx_json) : JS(tx); + 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) { + obj[JS(close_time_iso)] = xrpl::toStringIso(lgrInfo->closeTime); + obj[JS(ledger_hash)] = xrpl::strHex(lgrInfo->hash); + } + } + obj[JS(validated)] = true; + response.transactions.push_back(std::move(obj)); + continue; + } + } + + // binary is true + obj = toJsonWithBinaryTx(txnPlusMeta, ctx.apiVersion); + obj[JS(ledger_index)] = txnPlusMeta.ledgerSequence; + obj[JS(date)] = txnPlusMeta.date; + obj[JS(validated)] = true; + response.transactions.push_back(std::move(obj)); + } + + response.limit = input.limit; + response.mptIssuanceID = xrpl::to_string(mptIssuanceID); + response.ledgerIndexMin = minIndex; + response.ledgerIndexMax = maxIndex; + + return response; +} + +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) + jv.as_object()[JS(marker)] = boost::json::value_from(*(output.marker)); + + if (output.limit) + 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 0000000000..7fd0ba8a07 --- /dev/null +++ b/src/rpc/handlers/MPTokenIssuanceHistory.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include "data/BackendInterface.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 + +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 MPT + * transaction-history backfill reports `Migrated`, so partial history is never served. + */ +class MPTokenIssuanceHistoryHandler { + util::Logger log_{"RPC"}; + std::shared_ptr sharedPtrBackend_; + // Status is monotonic, so the terminal Migrated 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; + + // Literal rather than a reference to migration::cassandra::MPTTransactionHistoryMigrator::kName + // to keep the Cassandra migration headers out of the RPC layer. + static constexpr char const* kMigratorName = "MPTTransactionHistoryMigrator"; + + /** + * @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 better type than json + boost::json::array transactions; + // validated should be sent via framework + bool validated = true; + }; + + /** + * @brief A struct to hold the input data for the command. + * + * @note A request must use at least one of ledger_index, ledger_hash, ledger_index_min or + * ledger_index_max. + */ + 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 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 e67d37663f..e2dc42df02 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 0000000000..65afbc58c9 --- /dev/null +++ b/tests/unit/rpc/handlers/MPTokenIssuanceHistoryTests.cpp @@ -0,0 +1,1731 @@ +#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/NameGenerator.hpp" +#include "util/TestObject.hpp" + +#include +#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 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})); + } +}; + +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("MPTTransactionHistoryMigrator") != 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); + }); +} + +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")); + }); +}