diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 151a5927..b0de0f2d 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -31,6 +31,7 @@ target_sources( handlers/Ledger.cpp handlers/LedgerData.cpp handlers/LedgerEntry.cpp + handlers/LedgerIndex.cpp handlers/LedgerRange.cpp handlers/NFTsByIssuer.cpp handlers/NFTBuyOffers.cpp diff --git a/src/rpc/common/Validators.cpp b/src/rpc/common/Validators.cpp index 7cc3d038..5488259a 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -34,6 +34,9 @@ #include #include +#include +#include +#include #include #include #include @@ -51,6 +54,26 @@ Required::verify(boost::json::value const& value, std::string_view key) return {}; } +[[nodiscard]] MaybeError +TimeFormatValidator::verify(boost::json::value const& value, std::string_view key) const +{ + using boost::json::value_to; + + if (not value.is_object() or not value.as_object().contains(key)) + return {}; // ignore. field does not exist, let 'required' fail instead + + if (not value.as_object().at(key).is_string()) + return Error{Status{RippledError::rpcINVALID_PARAMS}}; + + std::tm time = {}; + std::stringstream stream(value_to(value.as_object().at(key))); + stream >> std::get_time(&time, format_.c_str()); + if (stream.fail()) + return Error{Status{RippledError::rpcINVALID_PARAMS}}; + + return {}; +} + [[nodiscard]] MaybeError CustomValidator::verify(boost::json::value const& value, std::string_view key) const { diff --git a/src/rpc/common/Validators.hpp b/src/rpc/common/Validators.hpp index d2c78a4e..51e4baea 100644 --- a/src/rpc/common/Validators.hpp +++ b/src/rpc/common/Validators.hpp @@ -30,8 +30,11 @@ #include #include +#include #include #include +#include +#include #include #include #include @@ -288,6 +291,33 @@ public: } }; +/** + * @brief Validate that value can be converted to time according to the given format. + */ +class TimeFormatValidator final { + std::string format_; + +public: + /** + * @brief Construct the validator storing format value. + * + * @param format The format to use for time conversion + */ + explicit TimeFormatValidator(std::string format) : format_{std::move(format)} + { + } + + /** + * @brief Verify that the JSON value is valid formatted time. + * + * @param value The JSON value representing the outer object + * @param key The key used to retrieve the tested value from the outer object + * @return `RippledError::rpcINVALID_PARAMS` if validation failed; otherwise no error is returned + */ + [[nodiscard]] MaybeError + verify(boost::json::value const& value, std::string_view key) const; +}; + /** * @brief Validates that the value is equal to the one passed in. */ diff --git a/src/rpc/common/impl/HandlerProvider.cpp b/src/rpc/common/impl/HandlerProvider.cpp index 88e86d62..5d0a825a 100644 --- a/src/rpc/common/impl/HandlerProvider.cpp +++ b/src/rpc/common/impl/HandlerProvider.cpp @@ -43,6 +43,7 @@ #include "rpc/handlers/Ledger.hpp" #include "rpc/handlers/LedgerData.hpp" #include "rpc/handlers/LedgerEntry.hpp" +#include "rpc/handlers/LedgerIndex.hpp" #include "rpc/handlers/LedgerRange.hpp" #include "rpc/handlers/NFTBuyOffers.hpp" #include "rpc/handlers/NFTHistory.hpp" @@ -94,6 +95,7 @@ ProductionHandlerProvider::ProductionHandlerProvider( {"ledger", {LedgerHandler{backend}}}, {"ledger_data", {LedgerDataHandler{backend}}}, {"ledger_entry", {LedgerEntryHandler{backend}}}, + {"ledger_index", {LedgerIndexHandler{backend}, true}}, // clio only {"ledger_range", {LedgerRangeHandler{backend}}}, {"nfts_by_issuer", {NFTsByIssuerHandler{backend}, true}}, // clio only {"nft_history", {NFTHistoryHandler{backend}, true}}, // clio only diff --git a/src/rpc/handlers/LedgerIndex.cpp b/src/rpc/handlers/LedgerIndex.cpp new file mode 100644 index 00000000..c33f2b98 --- /dev/null +++ b/src/rpc/handlers/LedgerIndex.cpp @@ -0,0 +1,119 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "rpc/handlers/LedgerIndex.hpp" + +#include "rpc/JS.hpp" +#include "rpc/common/Types.hpp" +#include "util/Assert.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rpc { + +LedgerIndexHandler::Result +LedgerIndexHandler::process(LedgerIndexHandler::Input input, Context const& ctx) const +{ + auto const range = sharedPtrBackend_->fetchLedgerRange(); + auto const [minIndex, maxIndex] = *range; + + auto const fillOutputByIndex = [&](std::uint32_t index) { + auto const ledger = sharedPtrBackend_->fetchLedgerBySequence(index, ctx.yield); + return Output{ + .ledgerIndex = index, + .ledgerHash = ripple::strHex(ledger->hash), + .closeTimeIso = ripple::to_string_iso(ledger->closeTime) + }; + }; + + // if no date is provided, return the latest ledger + if (!input.date) + return fillOutputByIndex(maxIndex); + + auto const convertISOTimeStrToTicks = [](std::string const& isoTimeStr) { + std::tm time = {}; + std::stringstream ss(isoTimeStr); + ss >> std::get_time(&time, DATE_FORMAT); + return std::chrono::system_clock::from_time_t(std::mktime(&time)).time_since_epoch().count(); + }; + + auto const ticks = convertISOTimeStrToTicks(*input.date); + + auto const earlierThan = [&](std::uint32_t ledgerIndex) { + auto const header = sharedPtrBackend_->fetchLedgerBySequence(ledgerIndex, ctx.yield); + auto const ledgerTime = + std::chrono::system_clock::time_point{header->closeTime.time_since_epoch() + ripple::epoch_offset}; + return ticks < ledgerTime.time_since_epoch().count(); + }; + + // If the given date is earlier than the first valid ledger, return lgrNotFound + if (earlierThan(minIndex)) + return Error{Status{RippledError::rpcLGR_NOT_FOUND, "ledgerNotInRange"}}; + + auto const view = std::ranges::iota_view{minIndex, maxIndex + 1}; + + auto const greaterEqLedgerIter = std::ranges::lower_bound( + view, ticks, [&](std::uint32_t ledgerIndex, std::int64_t) { return not earlierThan(ledgerIndex); } + ); + + if (greaterEqLedgerIter != view.end()) + return fillOutputByIndex(std::max(static_cast(*greaterEqLedgerIter) - 1, minIndex)); + + return fillOutputByIndex(maxIndex); +} + +LedgerIndexHandler::Input +tag_invoke(boost::json::value_to_tag, boost::json::value const& jv) +{ + auto input = LedgerIndexHandler::Input{}; + + if (jv.as_object().contains(JS(date))) + input.date = jv.at(JS(date)).as_string(); + + return input; +} + +void +tag_invoke(boost::json::value_from_tag, boost::json::value& jv, LedgerIndexHandler::Output const& output) +{ + jv = boost::json::object{ + {JS(ledger_index), output.ledgerIndex}, + {JS(ledger_hash), output.ledgerHash}, + {JS(close_time_iso), output.closeTimeIso}, + {JS(validated), true}, + }; +} + +} // namespace rpc diff --git a/src/rpc/handlers/LedgerIndex.hpp b/src/rpc/handlers/LedgerIndex.hpp new file mode 100644 index 00000000..c6777028 --- /dev/null +++ b/src/rpc/handlers/LedgerIndex.hpp @@ -0,0 +1,119 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once +#include "data/BackendInterface.hpp" +#include "rpc/JS.hpp" +#include "rpc/common/Specs.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/common/Validators.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace rpc { + +/** + * @brief The ledger_index method fetches the lastest closed ledger before the given date. + * + */ +class LedgerIndexHandler { + std::shared_ptr sharedPtrBackend_; + static constexpr auto DATE_FORMAT = "%Y-%m-%dT%TZ"; + +public: + /** + * @brief A struct to hold the output data of the command + */ + struct Output { + uint32_t ledgerIndex{}; + std::string ledgerHash; + std::string closeTimeIso; + }; + + /** + * @brief A struct to hold the input data for the command + */ + struct Input { + std::optional date; + }; + + using Result = HandlerReturnType; + + /** + * @brief Construct a new LedgerIndexHandler object + * + * @param sharedPtrBackend The backend to use + */ + LedgerIndexHandler(std::shared_ptr const& sharedPtrBackend) : sharedPtrBackend_(sharedPtrBackend) + { + } + + /** + * @brief Returns the API specification for the command + * + * @param apiVersion The api version to return the spec for + * @return The spec for the given apiVersion + */ + static RpcSpecConstRef + spec([[maybe_unused]] uint32_t apiVersion) + { + static auto const rpcSpec = RpcSpec{ + {JS(date), validation::Type{}, validation::TimeFormatValidator{DATE_FORMAT}}, + }; + return rpcSpec; + } + + /** + * @brief Process the LedgerIndex command + * + * @param input The input data for the command + * @param ctx The context of the request + * @return The result of the operation + */ + Result + process(Input input, Context const& ctx) const; + +private: + /** + * @brief Convert the Output to a JSON object + * + * @param [out] jv The JSON object to convert to + * @param output The output to convert + */ + friend void + tag_invoke(boost::json::value_from_tag, boost::json::value& jv, Output const& output); + + /** + * @brief Convert a JSON object to Input type + * + * @param jv The JSON object to convert + * @return Input parsed from the JSON object + */ + friend Input + tag_invoke(boost::json::value_to_tag, boost::json::value const& jv); +}; + +} // namespace rpc diff --git a/tests/common/util/TestObject.cpp b/tests/common/util/TestObject.cpp index 648b6354..2c9addc5 100644 --- a/tests/common/util/TestObject.cpp +++ b/tests/common/util/TestObject.cpp @@ -93,6 +93,21 @@ CreateLedgerHeader(std::string_view ledgerHash, ripple::LedgerIndex seq, std::op return ledgerHeader; } +ripple::LedgerHeader +CreateLedgerHeaderWithUnixTime(std::string_view ledgerHash, ripple::LedgerIndex seq, uint64_t closeTimeUnixStamp) +{ + using namespace std::chrono; + + auto ledgerHeader = ripple::LedgerHeader(); + ledgerHeader.hash = ripple::uint256{ledgerHash}; + ledgerHeader.seq = seq; + + auto const closeTime = closeTimeUnixStamp - seconds{rippleEpochStart}.count(); + ledgerHeader.closeTime = ripple::NetClock::time_point{seconds{closeTime}}; + + return ledgerHeader; +} + ripple::STObject CreateLegacyFeeSettingLedgerObject( uint64_t base, diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index ae289b6f..2fda0f3e 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -65,6 +65,12 @@ GetAccountKey(ripple::AccountID const& acc); [[nodiscard]] ripple::LedgerHeader CreateLedgerHeader(std::string_view ledgerHash, ripple::LedgerIndex seq, std::optional age = std::nullopt); +/* + * Create a simple ledgerHeader object with hash, seq and unix timestamp + */ +[[nodiscard]] ripple::LedgerHeader +CreateLedgerHeaderWithUnixTime(std::string_view ledgerHash, ripple::LedgerIndex seq, uint64_t closeTimeUnixStamp); + /* * Create a Legacy (pre XRPFees amendment) FeeSetting ledger object */ diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index e2740aef..38babe30 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -72,6 +72,7 @@ target_sources( rpc/handlers/GetAggregatePriceTests.cpp rpc/handlers/LedgerDataTests.cpp rpc/handlers/LedgerEntryTests.cpp + rpc/handlers/LedgerIndexTests.cpp rpc/handlers/LedgerRangeTests.cpp rpc/handlers/LedgerTests.cpp rpc/handlers/NFTBuyOffersTests.cpp diff --git a/tests/unit/rpc/BaseTests.cpp b/tests/unit/rpc/BaseTests.cpp index b6577a39..dde807e2 100644 --- a/tests/unit/rpc/BaseTests.cpp +++ b/tests/unit/rpc/BaseTests.cpp @@ -355,6 +355,42 @@ TEST_F(RPCBaseTest, WithCustomError) ASSERT_EQ(err.error(), ripple::rpcALREADY_MULTISIG); } +TEST_F(RPCBaseTest, TimeFormatValidator) +{ + auto const spec = RpcSpec{ + {"date", TimeFormatValidator{"%Y-%m-%dT%H:%M:%SZ"}}, + }; + + auto passingInput = json::parse(R"({ "date": "2023-01-01T00:00:00Z" })"); + EXPECT_TRUE(spec.process(passingInput)); + + passingInput = json::parse("123"); + EXPECT_TRUE(spec.process(passingInput)); + + // key not exists + passingInput = json::parse(R"({ "date1": "2023-01-01T00:00:00Z" })"); + EXPECT_TRUE(spec.process(passingInput)); + + auto failingInput = json::parse(R"({ "date": "2023-01-01-00:00:00" })"); + auto err = spec.process(failingInput); + EXPECT_FALSE(err); + EXPECT_EQ(err.error(), ripple::rpcINVALID_PARAMS); + + failingInput = json::parse(R"({ "date": "01-01-2024T00:00:00" })"); + EXPECT_FALSE(spec.process(failingInput)); + + failingInput = json::parse(R"({ "date": "2024-01-01T29:00:00" })"); + EXPECT_FALSE(spec.process(failingInput)); + + failingInput = json::parse(R"({ "date": "" })"); + EXPECT_FALSE(spec.process(failingInput)); + + failingInput = json::parse(R"({ "date": 1 })"); + err = spec.process(failingInput); + EXPECT_FALSE(err); + EXPECT_EQ(err.error(), ripple::rpcINVALID_PARAMS); +} + TEST_F(RPCBaseTest, CustomValidator) { auto customFormatCheck = CustomValidator{[](json::value const& value, std::string_view /* key */) -> MaybeError { diff --git a/tests/unit/rpc/handlers/LedgerIndexTests.cpp b/tests/unit/rpc/handlers/LedgerIndexTests.cpp new file mode 100644 index 00000000..09c6d6ff --- /dev/null +++ b/tests/unit/rpc/handlers/LedgerIndexTests.cpp @@ -0,0 +1,150 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "rpc/Errors.hpp" +#include "rpc/common/AnyHandler.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/handlers/LedgerIndex.hpp" +#include "util/HandlerBaseTestFixture.hpp" +#include "util/NameGenerator.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr static auto RANGEMIN = 10; +constexpr static auto RANGEMAX = 30; +constexpr static auto LEDGERHASH = "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; + +using namespace rpc; +namespace json = boost::json; +using namespace testing; + +class RPCLedgerIndexTest : public HandlerBaseTestStrict {}; + +TEST_F(RPCLedgerIndexTest, DateStrNotValid) +{ + auto const handler = AnyHandler{LedgerIndexHandler{backend}}; + auto const req = json::parse(R"({"date": "not_a_number"})"); + runSpawn([&](auto yield) { + 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(), "Invalid parameters."); + }); +} + +TEST_F(RPCLedgerIndexTest, NoDateGiven) +{ + backend->setRange(RANGEMIN, RANGEMAX); + auto const ledgerHeader = CreateLedgerHeader(LEDGERHASH, RANGEMAX, 5); + EXPECT_CALL(*backend, fetchLedgerBySequence(RANGEMAX, _)).WillOnce(Return(ledgerHeader)); + + auto const handler = AnyHandler{LedgerIndexHandler{backend}}; + auto const req = json::parse(R"({})"); + runSpawn([&](auto yield) { + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index").as_uint64(), RANGEMAX); + EXPECT_EQ(output.result->at("ledger_hash").as_string(), LEDGERHASH); + EXPECT_TRUE(output.result->as_object().contains("close_time_iso")); + }); +} + +TEST_F(RPCLedgerIndexTest, EarlierThanMinLedger) +{ + backend->setRange(RANGEMIN, RANGEMAX); + auto const handler = AnyHandler{LedgerIndexHandler{backend}}; + auto const req = json::parse(R"({"date": "2024-06-25T12:23:05Z"})"); + auto const ledgerHeader = + CreateLedgerHeaderWithUnixTime(LEDGERHASH, RANGEMIN, 1719318190); //"2024-06-25T12:23:10Z" + EXPECT_CALL(*backend, fetchLedgerBySequence(RANGEMIN, _)).WillOnce(Return(ledgerHeader)); + runSpawn([&](auto yield) { + 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"); + }); +} + +struct LedgerIndexTestsCaseBundle { + std::string testName; + std::string json; + std::uint32_t expectedLedgerIndex; + std::string closeTimeIso; +}; + +class LedgerIndexTests : public RPCLedgerIndexTest, public WithParamInterface { +public: + static auto + generateTestValuesForParametersTest() + { + // start from 2024-06-25T12:23:10Z to 2024-06-25T12:23:50Z with step 2 + return std::vector{ + {"LaterThanMaxLedger", R"({"date": "2024-06-25T12:23:55Z"})", RANGEMAX, "2024-06-25T12:23:50Z"}, + {"GreaterThanMinLedger", R"({"date": "2024-06-25T12:23:11Z"})", RANGEMIN, "2024-06-25T12:23:10Z"}, + {"IsMinLedger", R"({"date": "2024-06-25T12:23:10Z"})", RANGEMIN, "2024-06-25T12:23:10Z"}, + {"IsMaxLedger", R"({"date": "2024-06-25T12:23:50Z"})", RANGEMAX, "2024-06-25T12:23:50Z"}, + {"IsMidLedger", R"({"date": "2024-06-25T12:23:30Z"})", 20, "2024-06-25T12:23:30Z"}, + {"BetweenLedgers", R"({"date": "2024-06-25T12:23:29Z"})", 19, "2024-06-25T12:23:28Z"} + }; + } +}; + +INSTANTIATE_TEST_CASE_P( + RPCLedgerIndexTestsGroup, + LedgerIndexTests, + ValuesIn(LedgerIndexTests::generateTestValuesForParametersTest()), + tests::util::NameGenerator +); + +TEST_P(LedgerIndexTests, SearchFromLedgerRange) +{ + auto const testBundle = GetParam(); + auto const jv = json::parse(testBundle.json).as_object(); + backend->setRange(RANGEMIN, RANGEMAX); + + // start from 1719318190 , which is the unix time for 2024-06-25T12:23:10Z to 2024-06-25T12:23:50Z with + // step 2 + for (uint32_t i = RANGEMIN; i <= RANGEMAX; i++) { + auto const ledgerHeader = CreateLedgerHeaderWithUnixTime(LEDGERHASH, i, 1719318190 + 2 * (i - RANGEMIN)); + ON_CALL(*backend, fetchLedgerBySequence(i, _)).WillByDefault(Return(ledgerHeader)); + EXPECT_CALL(*backend, fetchLedgerBySequence(i, _)) + .Times(i == testBundle.expectedLedgerIndex ? (i == RANGEMIN ? Exactly(3) : Exactly(2)) : AtMost(1)); + } + + auto const handler = AnyHandler{LedgerIndexHandler{backend}}; + auto const req = json::parse(testBundle.json); + runSpawn([&](auto yield) { + auto const output = handler.process(req, Context{yield}); + ASSERT_TRUE(output); + EXPECT_EQ(output.result->at("ledger_index").as_uint64(), testBundle.expectedLedgerIndex); + EXPECT_EQ(output.result->at("ledger_hash").as_string(), LEDGERHASH); + EXPECT_EQ(output.result->at("close_time_iso").as_string(), testBundle.closeTimeIso); + }); +}