diff --git a/CMakeLists.txt b/CMakeLists.txt index e25385f2..dd2735ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ target_sources(clio PRIVATE src/rpc/ngHandlers/AccountChannels.cpp src/rpc/ngHandlers/AccountCurrencies.cpp src/rpc/ngHandlers/AccountLines.cpp + src/rpc/ngHandlers/AccountTx.cpp src/rpc/ngHandlers/Tx.cpp src/rpc/ngHandlers/GatewayBalances.cpp src/rpc/ngHandlers/LedgerEntry.cpp @@ -68,7 +69,7 @@ target_sources(clio PRIVATE src/rpc/ngHandlers/TransactionEntry.cpp src/rpc/ngHandlers/NoRippleCheck.cpp src/rpc/ngHandlers/NFTInfo.cpp - src/rpc/ngHandlers/AccountTx.cpp + src/rpc/ngHandlers/NFTBuyOffers.cpp ## RPC Methods # Account src/rpc/handlers/AccountChannels.cpp @@ -129,6 +130,7 @@ if(BUILD_TESTS) unittests/rpc/handlers/TestHandlerTests.cpp unittests/rpc/handlers/AccountCurrenciesTest.cpp unittests/rpc/handlers/AccountLinesTest.cpp + unittests/rpc/handlers/AccountTxTest.cpp unittests/rpc/handlers/DefaultProcessorTests.cpp unittests/rpc/handlers/PingTest.cpp unittests/rpc/handlers/AccountChannelsTest.cpp @@ -140,7 +142,7 @@ if(BUILD_TESTS) unittests/rpc/handlers/TransactionEntryTest.cpp unittests/rpc/handlers/NoRippleCheckTest.cpp unittests/rpc/handlers/NFTInfoTest.cpp - unittests/rpc/handlers/AccountTxTest.cpp) + unittests/rpc/handlers/NFTBuyOffersTest.cpp) include(CMake/deps/gtest.cmake) # if CODE_COVERAGE enable, add clio_test-ccov diff --git a/src/rpc/handlers/NFTOffers.cpp b/src/rpc/handlers/NFTOffers.cpp index 179dde5a..31ac32b5 100644 --- a/src/rpc/handlers/NFTOffers.cpp +++ b/src/rpc/handlers/NFTOffers.cpp @@ -33,7 +33,7 @@ namespace json = boost::json; namespace ripple { -void +inline void tag_invoke(json::value_from_tag, json::value& jv, SLE const& offer) { auto amount = ::RPC::toBoostJson( diff --git a/src/rpc/ngHandlers/NFTBuyOffers.cpp b/src/rpc/ngHandlers/NFTBuyOffers.cpp new file mode 100644 index 00000000..266f0c3d --- /dev/null +++ b/src/rpc/ngHandlers/NFTBuyOffers.cpp @@ -0,0 +1,226 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, 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 +#include + +#include +#include + +using namespace ripple; +using namespace ::RPC; + +namespace ripple { + +// TODO: move to some common serialization impl place +inline void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + SLE const& offer) +{ + auto amount = ::RPC::toBoostJson( + offer.getFieldAmount(sfAmount).getJson(JsonOptions::none)); + + boost::json::object obj = { + {JS(nft_offer_index), to_string(offer.key())}, + {JS(flags), offer[sfFlags]}, + {JS(owner), toBase58(offer.getAccountID(sfOwner))}, + {JS(amount), std::move(amount)}, + }; + + if (offer.isFieldPresent(sfDestination)) + obj.insert_or_assign( + JS(destination), toBase58(offer.getAccountID(sfDestination))); + + if (offer.isFieldPresent(sfExpiration)) + obj.insert_or_assign(JS(expiration), offer.getFieldU32(sfExpiration)); + + jv = std::move(obj); +} + +} // namespace ripple + +namespace RPCng { + +NFTBuyOffersHandler::Result +NFTBuyOffersHandler::process( + NFTBuyOffersHandler::Input input, + boost::asio::yield_context& yield) const +{ + auto const tokenID = ripple::uint256{input.nftID.c_str()}; + auto const range = sharedPtrBackend_->fetchLedgerRange(); + auto const lgrInfoOrStatus = getLedgerInfoFromHashOrSeq( + *sharedPtrBackend_, + yield, + input.ledgerHash, + input.ledgerIndex, + range->maxSequence); + if (auto const status = std::get_if(&lgrInfoOrStatus)) + return Error{*status}; + + auto const lgrInfo = std::get(lgrInfoOrStatus); + auto const directory = keylet::nft_buys(tokenID); + + // TODO: just check for existence without pulling + if (not sharedPtrBackend_->fetchLedgerObject( + directory.key, lgrInfo.seq, yield)) + return Error{Status{RippledError::rpcOBJECT_NOT_FOUND, "notFound"}}; + + auto output = NFTBuyOffersHandler::Output{input.nftID}; + auto offers = std::vector{}; + auto reserve = input.limit; + auto cursor = uint256{}; + auto startHint = uint64_t{0ul}; + + if (input.marker) + { + cursor = uint256(input.marker->c_str()); + + // We have a start point. Use limit - 1 from the result and use the very + // last one for the resume. + auto const sle = + [this, &cursor, &lgrInfo, &yield]() -> std::shared_ptr { + auto const key = keylet::nftoffer(cursor).key; + if (auto const blob = sharedPtrBackend_->fetchLedgerObject( + key, lgrInfo.seq, yield); + blob) + { + return std::make_shared( + SerialIter{blob->data(), blob->size()}, key); + } + return nullptr; + }(); + + if (!sle || + sle->getFieldU16(ripple::sfLedgerEntryType) != + ripple::ltNFTOKEN_OFFER || + tokenID != sle->getFieldH256(ripple::sfNFTokenID)) + { + return Error{Status{RippledError::rpcINVALID_PARAMS}}; + } + + startHint = sle->getFieldU64(ripple::sfNFTokenOfferNode); + output.offers.push_back(*sle); + offers.reserve(reserve); + } + else + { + // We have no start point, limit should be one higher than requested. + offers.reserve(++reserve); + } + + auto result = traverseOwnedNodes( + *sharedPtrBackend_, + directory, + cursor, + startHint, + lgrInfo.seq, + reserve, + {}, + yield, + [&offers](ripple::SLE&& offer) { + if (offer.getType() == ripple::ltNFTOKEN_OFFER) + { + offers.push_back(std::move(offer)); + return true; + } + + return false; + }); + + if (auto status = std::get_if(&result)) + return Error{*status}; + + if (offers.size() == reserve) + { + output.limit = input.limit; + output.marker = to_string(offers.back().key()); + offers.pop_back(); + } + + std::move( + std::begin(offers), + std::end(offers), + std::back_inserter(output.offers)); + + return std::move(output); +} + +void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + NFTBuyOffersHandler::Output const& output) +{ + auto object = boost::json::object{ + {JS(nft_id), output.nftID}, + {JS(validated), output.validated}, + {JS(offers), output.offers}, + }; + + if (output.marker) + object[JS(marker)] = *(output.marker); + if (output.limit) + object[JS(limit)] = *(output.limit); + + jv = std::move(object); +} + +NFTBuyOffersHandler::Input +tag_invoke( + boost::json::value_to_tag, + boost::json::value const& jv) +{ + auto const& jsonObject = jv.as_object(); + NFTBuyOffersHandler::Input input; + + input.nftID = jsonObject.at(JS(nft_id)).as_string().c_str(); + + if (jsonObject.contains(JS(ledger_hash))) + { + input.ledgerHash = jsonObject.at(JS(ledger_hash)).as_string().c_str(); + } + + if (jsonObject.contains(JS(ledger_index))) + { + if (!jsonObject.at(JS(ledger_index)).is_string()) + { + input.ledgerIndex = jsonObject.at(JS(ledger_index)).as_int64(); + } + else if (jsonObject.at(JS(ledger_index)).as_string() != "validated") + { + input.ledgerIndex = + std::stoi(jsonObject.at(JS(ledger_index)).as_string().c_str()); + } + } + + if (jsonObject.contains(JS(marker))) + { + input.marker = jsonObject.at(JS(marker)).as_string().c_str(); + } + + if (jsonObject.contains(JS(limit))) + { + input.limit = jsonObject.at(JS(limit)).as_int64(); + } + + return input; +} +} // namespace RPCng diff --git a/src/rpc/ngHandlers/NFTBuyOffers.h b/src/rpc/ngHandlers/NFTBuyOffers.h new file mode 100644 index 00000000..ef1b5e24 --- /dev/null +++ b/src/rpc/ngHandlers/NFTBuyOffers.h @@ -0,0 +1,94 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, 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 +#include +#include +#include + +#include + +namespace RPCng { +class NFTBuyOffersHandler +{ + std::shared_ptr sharedPtrBackend_; + +public: + struct Output + { + std::string nftID; + std::vector offers; + + // validated should be sent via framework + bool validated = true; + std::optional limit; + std::optional marker; + }; + + struct Input + { + std::string nftID; + std::optional ledgerHash; + std::optional ledgerIndex; + uint32_t limit = 250; + std::optional marker; + }; + + using Result = RPCng::HandlerReturnType; + + NFTBuyOffersHandler( + std::shared_ptr const& sharedPtrBackend) + : sharedPtrBackend_(sharedPtrBackend) + { + } + + RpcSpecConstRef + spec() const + { + static auto const rpcSpec = RpcSpec{ + {JS(nft_id), + validation::Required{}, + validation::Uint256HexStringValidator}, + {JS(ledger_hash), validation::Uint256HexStringValidator}, + {JS(ledger_index), validation::LedgerIndexValidator}, + {JS(limit), + validation::Type{}, + validation::Between{50, 500}}, + {JS(marker), validation::Uint256HexStringValidator}, + }; + return rpcSpec; + } + + Result + process(Input input, boost::asio::yield_context& yield) const; +}; + +void +tag_invoke( + boost::json::value_from_tag, + boost::json::value& jv, + NFTBuyOffersHandler::Output const& output); + +NFTBuyOffersHandler::Input +tag_invoke( + boost::json::value_to_tag, + boost::json::value const& jv); +} // namespace RPCng diff --git a/unittests/rpc/handlers/AccountChannelsTest.cpp b/unittests/rpc/handlers/AccountChannelsTest.cpp index 7174244a..b0ad66ef 100644 --- a/unittests/rpc/handlers/AccountChannelsTest.cpp +++ b/unittests/rpc/handlers/AccountChannelsTest.cpp @@ -111,7 +111,7 @@ TEST_F(RPCAccountHandlerTest, MarkerNotString) auto const input = json::parse(fmt::format( R"({{ "account": "{}", - "marker":9 + "marker": 9 }})", ACCOUNT)); auto const output = handler.process(input, yield); @@ -148,7 +148,7 @@ TEST_F(RPCAccountHandlerTest, InvalidMarker) auto const input = json::parse(fmt::format( R"({{ "account": "{}", - "marker":401 + "marker": 401 }})", ACCOUNT)); auto const output = handler.process(input, yield); @@ -167,7 +167,7 @@ TEST_F(RPCAccountHandlerTest, IncorrectLimit) auto const input = json::parse(fmt::format( R"({{ "account": "{}", - "limit":9 + "limit": 9 }})", ACCOUNT)); auto const output = handler.process(input, yield); @@ -181,7 +181,7 @@ TEST_F(RPCAccountHandlerTest, IncorrectLimit) auto const input = json::parse(fmt::format( R"({{ "account": "{}", - "limit":401 + "limit": 401 }})", ACCOUNT)); auto const output = handler.process(input, yield); @@ -198,7 +198,7 @@ TEST_F(RPCAccountHandlerTest, AccountInvalidFormat) runSpawn([this](auto& yield) { auto const handler = AnyHandler{AccountChannelsHandler{mockBackendPtr}}; auto const input = json::parse(R"({ - "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jp" + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jp" })"); auto const output = handler.process(input, yield); ASSERT_FALSE(output); @@ -214,7 +214,7 @@ TEST_F(RPCAccountHandlerTest, AccountNotString) runSpawn([this](auto& yield) { auto const handler = AnyHandler{AccountChannelsHandler{mockBackendPtr}}; auto const input = json::parse(R"({ - "account": 12 + "account": 12 })"); auto const output = handler.process(input, yield); ASSERT_FALSE(output); @@ -237,8 +237,8 @@ TEST_F(RPCAccountHandlerTest, NonExistLedgerViaLedgerHash) auto const input = json::parse(fmt::format( R"({{ - "account": "{}", - "ledger_hash": "{}" + "account": "{}", + "ledger_hash": "{}" }})", ACCOUNT, LEDGERHASH)); @@ -266,8 +266,8 @@ TEST_F(RPCAccountHandlerTest, NonExistLedgerViaLedgerIndex) EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); auto const input = json::parse(fmt::format( R"({{ - "account": "{}", - "ledger_index": "4" + "account": "{}", + "ledger_index": "4" }})", ACCOUNT)); runSpawn([&, this](auto& yield) { diff --git a/unittests/rpc/handlers/NFTBuyOffersTest.cpp b/unittests/rpc/handlers/NFTBuyOffersTest.cpp new file mode 100644 index 00000000..da39476d --- /dev/null +++ b/unittests/rpc/handlers/NFTBuyOffersTest.cpp @@ -0,0 +1,622 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, 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 +#include +#include +#include + +#include + +using namespace RPCng; +namespace json = boost::json; +using namespace testing; + +constexpr static auto ACCOUNT = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; +constexpr static auto LEDGERHASH = + "4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A652"; +constexpr static auto NFTID = + "00010000A7CAD27B688D14BA1A9FA5366554D6ADCF9CE0875B974D9F00000004"; +constexpr static auto INDEX1 = + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321"; +constexpr static auto INDEX2 = + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322"; + +class RPCNFTBuyOffersHandlerTest : public HandlerBaseTest +{ +}; + +TEST_F(RPCNFTBuyOffersHandlerTest, NonHexLedgerHash) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_hash": "xxx" + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "ledger_hashMalformed"); + }); +} + +TEST_F(RPCNFTBuyOffersHandlerTest, NonStringLedgerHash) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_hash": 123 + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "ledger_hashNotString"); + }); +} + +TEST_F(RPCNFTBuyOffersHandlerTest, InvalidLedgerIndexString) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_index": "notvalidated" + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerIndexMalformed"); + }); +} + +// error case: nft_id invalid format, length is incorrect +TEST_F(RPCNFTBuyOffersHandlerTest, NFTIDInvalidFormat) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(R"({ + "nft_id": "00080000B4F4AFC5FBCBD76873F18006173D2193467D3EE7" + })"); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "nft_idMalformed"); + }); +} + +// error case: nft_id invalid format +TEST_F(RPCNFTBuyOffersHandlerTest, NFTIDNotString) +{ + runSpawn([this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(R"({ + "nft_id": 12 + })"); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "nft_idNotString"); + }); +} + +// error case ledger non exist via hash +TEST_F(RPCNFTBuyOffersHandlerTest, NonExistLedgerViaLedgerHash) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + // mock fetchLedgerByHash return empty + ON_CALL(*rawBackendPtr, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)) + .WillByDefault(Return(std::optional{})); + EXPECT_CALL(*rawBackendPtr, fetchLedgerByHash).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_hash": "{}" + }})", + NFTID, + LEDGERHASH)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// error case ledger non exist via index +TEST_F(RPCNFTBuyOffersHandlerTest, NonExistLedgerViaLedgerIndex) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + // mock fetchLedgerBySequence return empty + ON_CALL(*rawBackendPtr, fetchLedgerBySequence) + .WillByDefault(Return(std::optional{})); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_index": "4" + }})", + NFTID)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// error case ledger > max seq via hash +// idk why this case will happen in reality +TEST_F(RPCNFTBuyOffersHandlerTest, NonExistLedgerViaLedgerHash2) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + // mock fetchLedgerByHash return ledger but seq is 31 > 30 + auto ledgerinfo = CreateLedgerInfo(LEDGERHASH, 31); + ON_CALL(*rawBackendPtr, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)) + .WillByDefault(Return(ledgerinfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerByHash).Times(1); + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_hash": "{}" + }})", + NFTID, + LEDGERHASH)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// error case ledger > max seq via index +TEST_F(RPCNFTBuyOffersHandlerTest, NonExistLedgerViaLedgerIndex2) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + // no need to check from db, call fetchLedgerBySequence 0 time + // differ from previous logic + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(0); + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_index": "31" + }})", + NFTID)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "lgrNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "ledgerNotFound"); + }); +} + +// error case when nft is not found +TEST_F(RPCNFTBuyOffersHandlerTest, NoNFT) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + auto ledgerinfo = CreateLedgerInfo(LEDGERHASH, 30); + ON_CALL(*rawBackendPtr, fetchLedgerByHash(ripple::uint256{LEDGERHASH}, _)) + .WillByDefault(Return(ledgerinfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerByHash).Times(1); + ON_CALL(*rawBackendPtr, doFetchLedgerObject) + .WillByDefault(Return(std::nullopt)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(1); + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "ledger_hash": "{}" + }})", + NFTID, + LEDGERHASH)); + runSpawn([&, this](boost::asio::yield_context yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "objectNotFound"); + EXPECT_EQ(err.at("error_message").as_string(), "notFound"); + }); +} + +TEST_F(RPCNFTBuyOffersHandlerTest, MarkerNotString) +{ + runSpawn([this](auto& yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "marker": 9 + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "markerNotString"); + }); +} + +// error case : invalid marker +// marker format in this RPC is a hex-string of a ripple::uint256. +TEST_F(RPCNFTBuyOffersHandlerTest, InvalidMarker) +{ + runSpawn([this](auto& yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "marker": "123invalid" + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + EXPECT_EQ(err.at("error_message").as_string(), "markerMalformed"); + }); + runSpawn([&, this](auto& yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "marker": 250 + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + }); +} + +// the limit is between 50 500 +TEST_F(RPCNFTBuyOffersHandlerTest, IncorrectLimit) +{ + runSpawn([this](auto& yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "limit": 49 + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + }); + runSpawn([this](auto& yield) { + auto const handler = AnyHandler{NFTBuyOffersHandler{mockBackendPtr}}; + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "limit": 501 + }})", + NFTID)); + auto const output = handler.process(input, yield); + ASSERT_FALSE(output); + + auto const err = RPC::makeError(output.error()); + EXPECT_EQ(err.at("error").as_string(), "invalidParams"); + }); +} + +// normal case when only provide nft_id +TEST_F(RPCNFTBuyOffersHandlerTest, DefaultParameters) +{ + constexpr static auto correctOutput = R"({ + "nft_id": "00010000A7CAD27B688D14BA1A9FA5366554D6ADCF9CE0875B974D9F00000004", + "validated": true, + "offers": [ + { + "nft_offer_index": "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC321", + "flags": 0, + "owner": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "amount": "123" + }, + { + "nft_offer_index": "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC322", + "flags": 0, + "owner": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "amount": "123" + } + ] + })"; + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + auto ledgerInfo = CreateLedgerInfo(LEDGERHASH, 30); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence) + .WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + + // return owner index containing 2 indexes + auto const directory = ripple::keylet::nft_buys(ripple::uint256{NFTID}); + auto const ownerDir = CreateOwnerDirLedgerObject( + {ripple::uint256{INDEX1}, ripple::uint256{INDEX2}}, INDEX1); + + ON_CALL( + *rawBackendPtr, + doFetchLedgerObject(directory.key, testing::_, testing::_)) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL( + *rawBackendPtr, + doFetchLedgerObject(directory.key, testing::_, testing::_)) + .Times(2); + + // return two nft buy offers + std::vector bbs; + auto const offer = CreateNFTBuyOffer(NFTID, ACCOUNT); + bbs.push_back(offer.getSerializer().peekData()); + bbs.push_back(offer.getSerializer().peekData()); + ON_CALL(*rawBackendPtr, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObjects).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}" + }})", + NFTID)); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{NFTBuyOffersHandler{this->mockBackendPtr}}; + auto const output = handler.process(input, yield); + + ASSERT_TRUE(output); + EXPECT_EQ(json::parse(correctOutput), *output); + }); +} + +// normal case when provided with nft_id and limit +TEST_F(RPCNFTBuyOffersHandlerTest, MultipleResultsWithMarkerAndLimitOutput) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + auto ledgerInfo = CreateLedgerInfo(LEDGERHASH, 30); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence) + .WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + + // return owner index + std::vector indexes; + std::vector bbs; + auto repetitions = 500; + auto const offer = CreateNFTBuyOffer(NFTID, ACCOUNT); + auto idx = ripple::uint256{INDEX1}; + while (repetitions--) + { + indexes.push_back(idx++); + bbs.push_back(offer.getSerializer().peekData()); + } + ripple::STObject ownerDir = CreateOwnerDirLedgerObject(indexes, INDEX1); + + ON_CALL(*rawBackendPtr, doFetchLedgerObject) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObject).Times(2); + + ON_CALL(*rawBackendPtr, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObjects).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "limit": 50 + }})", + NFTID)); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{NFTBuyOffersHandler{this->mockBackendPtr}}; + auto const output = handler.process(input, yield); + + ASSERT_TRUE(output); + EXPECT_EQ(output->at("offers").as_array().size(), 50); + EXPECT_EQ(output->at("limit").as_uint64(), 50); + EXPECT_STREQ( + output->at("marker").as_string().c_str(), + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC353"); + }); +} + +// normal case when provided with nft_id, limit and marker +TEST_F(RPCNFTBuyOffersHandlerTest, ResultsForInputWithMarkerAndLimit) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + auto ledgerInfo = CreateLedgerInfo(LEDGERHASH, 30); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence) + .WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + + // return owner index + std::vector indexes; + std::vector bbs; + auto repetitions = 500; + auto const offer = CreateNFTBuyOffer(NFTID, ACCOUNT); + auto idx = ripple::uint256{INDEX1}; + while (repetitions--) + { + indexes.push_back(idx++); + bbs.push_back(offer.getSerializer().peekData()); + } + ripple::STObject ownerDir = CreateOwnerDirLedgerObject(indexes, INDEX1); + auto const cursorBuyOffer = CreateNFTBuyOffer(NFTID, ACCOUNT); + + // first is nft offer object + auto const cursor = ripple::uint256{ + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC353"}; + auto const first = ripple::keylet::nftoffer(cursor); + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(first.key, testing::_, testing::_)) + .WillByDefault(Return(cursorBuyOffer.getSerializer().peekData())); + EXPECT_CALL( + *rawBackendPtr, doFetchLedgerObject(first.key, testing::_, testing::_)) + .Times(1); + + auto const directory = ripple::keylet::nft_buys(ripple::uint256{NFTID}); + auto const startHint = 0ul; // offer node is hardcoded to 0ul + auto const secondKey = ripple::keylet::page(directory, startHint).key; + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(secondKey, testing::_, testing::_)) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL( + *rawBackendPtr, doFetchLedgerObject(secondKey, testing::_, testing::_)) + .Times(3); + + ON_CALL(*rawBackendPtr, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObjects).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "marker": "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC353", + "limit": 50 + }})", + NFTID)); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{NFTBuyOffersHandler{this->mockBackendPtr}}; + auto const output = handler.process(input, yield); + + ASSERT_TRUE(output); + EXPECT_EQ(output->at("offers").as_array().size(), 50); + EXPECT_EQ(output->at("limit").as_uint64(), 50); + // marker also progressed by 50 + EXPECT_STREQ( + output->at("marker").as_string().c_str(), + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC385"); + }); +} + +// normal case when provided with nft_id, limit and marker +// nothing left after reading remaining 50 entries +TEST_F( + RPCNFTBuyOffersHandlerTest, + ResultsWithoutMarkerForInputWithMarkerAndLimit) +{ + MockBackend* rawBackendPtr = + static_cast(mockBackendPtr.get()); + mockBackendPtr->updateRange(10); // min + mockBackendPtr->updateRange(30); // max + auto ledgerInfo = CreateLedgerInfo(LEDGERHASH, 30); + ON_CALL(*rawBackendPtr, fetchLedgerBySequence) + .WillByDefault(Return(ledgerInfo)); + EXPECT_CALL(*rawBackendPtr, fetchLedgerBySequence).Times(1); + + // return owner index + std::vector indexes; + std::vector bbs; + auto repetitions = 100; + auto const offer = CreateNFTBuyOffer(NFTID, ACCOUNT); + auto idx = ripple::uint256{INDEX1}; + while (repetitions--) + { + indexes.push_back(idx++); + bbs.push_back(offer.getSerializer().peekData()); + } + ripple::STObject ownerDir = CreateOwnerDirLedgerObject(indexes, INDEX1); + auto const cursorBuyOffer = CreateNFTBuyOffer(NFTID, ACCOUNT); + + // first is nft offer object + auto const cursor = ripple::uint256{ + "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC353"}; + auto const first = ripple::keylet::nftoffer(cursor); + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(first.key, testing::_, testing::_)) + .WillByDefault(Return(cursorBuyOffer.getSerializer().peekData())); + EXPECT_CALL( + *rawBackendPtr, doFetchLedgerObject(first.key, testing::_, testing::_)) + .Times(1); + + auto const directory = ripple::keylet::nft_buys(ripple::uint256{NFTID}); + auto const startHint = 0ul; // offer node is hardcoded to 0ul + auto const secondKey = ripple::keylet::page(directory, startHint).key; + ON_CALL( + *rawBackendPtr, doFetchLedgerObject(secondKey, testing::_, testing::_)) + .WillByDefault(Return(ownerDir.getSerializer().peekData())); + EXPECT_CALL( + *rawBackendPtr, doFetchLedgerObject(secondKey, testing::_, testing::_)) + .Times(3); + + ON_CALL(*rawBackendPtr, doFetchLedgerObjects).WillByDefault(Return(bbs)); + EXPECT_CALL(*rawBackendPtr, doFetchLedgerObjects).Times(1); + + auto const input = json::parse(fmt::format( + R"({{ + "nft_id": "{}", + "marker": "E6DBAFC99223B42257915A63DFC6B0C032D4070F9A574B255AD97466726FC353", + "limit": 50 + }})", + NFTID)); + runSpawn([&, this](auto& yield) { + auto handler = AnyHandler{NFTBuyOffersHandler{this->mockBackendPtr}}; + auto const output = handler.process(input, yield); + + ASSERT_TRUE(output); + EXPECT_EQ(output->at("offers").as_array().size(), 50); + // no marker/limit to output - we read all items already + EXPECT_FALSE(output->as_object().contains("limit")); + EXPECT_FALSE(output->as_object().contains("marker")); + }); +} diff --git a/unittests/util/TestObject.cpp b/unittests/util/TestObject.cpp index e32b8903..ab4a46cf 100644 --- a/unittests/util/TestObject.cpp +++ b/unittests/util/TestObject.cpp @@ -471,3 +471,19 @@ CreateNFT( uri, isBurned}; } + +ripple::STObject +CreateNFTBuyOffer(std::string_view tokenID, std::string_view account) +{ + ripple::STObject offer(ripple::sfLedgerEntry); + offer.setFieldH256(ripple::sfNFTokenID, ripple::uint256{tokenID}); + offer.setFieldU16(ripple::sfLedgerEntryType, ripple::ltNFTOKEN_OFFER); + offer.setFieldU32(ripple::sfFlags, 0u); + offer.setFieldAmount(ripple::sfAmount, ripple::STAmount{123}); + offer.setFieldU64(ripple::sfOwnerNode, 0ul); + offer.setAccountID(ripple::sfOwner, GetAccountIDWithString(account)); + offer.setFieldH256(ripple::sfPreviousTxnID, ripple::uint256{}); + offer.setFieldU32(ripple::sfPreviousTxnLgrSeq, 0u); + offer.setFieldU64(ripple::sfNFTokenOfferNode, 0ul); + return offer; +} diff --git a/unittests/util/TestObject.h b/unittests/util/TestObject.h index ec9b6a31..93e30991 100644 --- a/unittests/util/TestObject.h +++ b/unittests/util/TestObject.h @@ -181,7 +181,7 @@ CreateRippleStateLedgerObject( uint32_t previousTxnSeq, uint32_t flag = 0); -ripple::STObject +[[nodiscard]] ripple::STObject CreateOfferLedgerObject( std::string_view account, int takerGets, @@ -192,16 +192,16 @@ CreateOfferLedgerObject( std::string_view paysIssueId, std::string_view bookDirId); -ripple::STObject +[[nodiscard]] ripple::STObject CreateTicketLedgerObject(std::string_view rootIndex, uint32_t sequence); -ripple::STObject +[[nodiscard]] ripple::STObject CreateEscrowLedgerObject(std::string_view account, std::string_view dest); -ripple::STObject +[[nodiscard]] ripple::STObject CreateCheckLedgerObject(std::string_view account, std::string_view dest); -ripple::STObject +[[nodiscard]] ripple::STObject CreateDepositPreauthLedgerObject( std::string_view account, std::string_view auth); @@ -213,3 +213,6 @@ CreateNFT( ripple::LedgerIndex seq = 1234u, ripple::Blob uri = ripple::Blob{'u', 'r', 'i'}, bool isBurned = false); + +[[nodiscard]] ripple::STObject +CreateNFTBuyOffer(std::string_view tokenID, std::string_view account);