diff --git a/src/ripple/app/tx/impl/InvariantCheck.cpp b/src/ripple/app/tx/impl/InvariantCheck.cpp index 82f4cea6b3d..8664c6492b9 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.cpp +++ b/src/ripple/app/tx/impl/InvariantCheck.cpp @@ -18,6 +18,8 @@ //============================================================================== #include + +#include #include #include #include @@ -493,23 +495,27 @@ ValidNewAccountRoot::finalize( void ValidNFTokenPage::visitEntry( - bool, + bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { static constexpr uint256 const& pageBits = nft::pageMask; static constexpr uint256 const accountBits = ~pageBits; - auto check = [this](std::shared_ptr const& sle) { - auto const account = sle->key() & accountBits; - auto const limit = sle->key() & pageBits; + auto check = [this, isDelete](std::shared_ptr const& sle) { + uint256 const account = sle->key() & accountBits; + uint256 const hiLimit = sle->key() & pageBits; + std::optional const prev = (*sle)[~sfPreviousPageMin]; - if (auto const prev = (*sle)[~sfPreviousPageMin]) + // Make sure that any page links... + // 1. Are properly associated with the owning account and + // 2. The page is correctly ordered between links. + if (prev) { if (account != (*prev & accountBits)) badLink_ = true; - if (limit <= (*prev & pageBits)) + if (hiLimit <= (*prev & pageBits)) badLink_ = true; } @@ -518,17 +524,42 @@ ValidNFTokenPage::visitEntry( if (account != (*next & accountBits)) badLink_ = true; - if (limit >= (*next & pageBits)) + if (hiLimit >= (*next & pageBits)) badLink_ = true; } - for (auto const& obj : sle->getFieldArray(sfNFTokens)) { - if ((obj[sfNFTokenID] & pageBits) >= limit) - badEntry_ = true; - - if (auto uri = obj[~sfURI]; uri && uri->empty()) - badURI_ = true; + auto const& nftokens = sle->getFieldArray(sfNFTokens); + + // An NFTokenPage should never contain too many tokens or be empty. + if (std::size_t const nftokenCount = nftokens.size(); + (!isDelete && nftokenCount == 0) || + nftokenCount > dirMaxTokensPerPage) + invalidSize_ = true; + + // If prev is valid, use it to establish a lower bound for + // page entries. If prev is not valid the lower bound is zero. + uint256 const loLimit = + prev ? *prev & pageBits : uint256(beast::zero); + + // Also verify that all NFTokenIDs in the page are sorted. + uint256 loCmp = loLimit; + for (auto const& obj : nftokens) + { + uint256 const tokenID = obj[sfNFTokenID]; + if (!nft::compareTokens(loCmp, tokenID)) + badSort_ = true; + loCmp = tokenID; + + // None of the NFTs on this page should belong on lower or + // higher pages. + if (uint256 const tokenPageBits = tokenID & pageBits; + tokenPageBits < loLimit || tokenPageBits >= hiLimit) + badEntry_ = true; + + if (auto uri = obj[~sfURI]; uri && uri->empty()) + badURI_ = true; + } } }; @@ -559,12 +590,24 @@ ValidNFTokenPage::finalize( return false; } + if (badSort_) + { + JLOG(j.fatal()) << "Invariant failed: NFTs on page are not sorted."; + return false; + } + if (badURI_) { JLOG(j.fatal()) << "Invariant failed: NFT contains empty URI."; return false; } + if (invalidSize_) + { + JLOG(j.fatal()) << "Invariant failed: NFT page has invalid size."; + return false; + } + return true; } diff --git a/src/ripple/app/tx/impl/InvariantCheck.h b/src/ripple/app/tx/impl/InvariantCheck.h index 5936b59b6a8..c3bb0216426 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.h +++ b/src/ripple/app/tx/impl/InvariantCheck.h @@ -320,9 +320,11 @@ class ValidNewAccountRoot class ValidNFTokenPage { - bool badLink_ = false; bool badEntry_ = false; + bool badLink_ = false; + bool badSort_ = false; bool badURI_ = false; + bool invalidSize_ = false; public: void diff --git a/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp index b7997996e40..7c78f175f63 100644 --- a/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp +++ b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp @@ -63,36 +63,33 @@ NFTokenAcceptOffer::preflight(PreflightContext const& ctx) TER NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) { - auto const checkOffer = [&ctx](std::optional id) -> TER { + auto const checkOffer = [&ctx](std::optional id) + -> std::pair, TER> { if (id) { - auto const offer = ctx.view.read(keylet::nftoffer(*id)); + auto offerSLE = ctx.view.read(keylet::nftoffer(*id)); - if (!offer) - return tecOBJECT_NOT_FOUND; + if (!offerSLE) + return {nullptr, tecOBJECT_NOT_FOUND}; - if (hasExpired(ctx.view, (*offer)[~sfExpiration])) - return tecEXPIRED; - } + if (hasExpired(ctx.view, (*offerSLE)[~sfExpiration])) + return {nullptr, tecEXPIRED}; - return tesSUCCESS; + return {std::move(offerSLE), tesSUCCESS}; + } + return {nullptr, tesSUCCESS}; }; - auto const buy = ctx.tx[~sfNFTokenBuyOffer]; - auto const sell = ctx.tx[~sfNFTokenSellOffer]; - - if (auto const ret = checkOffer(buy); !isTesSuccess(ret)) - return ret; - - if (auto const ret = checkOffer(sell); !isTesSuccess(ret)) - return ret; + auto const [bo, err1] = checkOffer(ctx.tx[~sfNFTokenBuyOffer]); + if (!isTesSuccess(err1)) + return err1; + auto const [so, err2] = checkOffer(ctx.tx[~sfNFTokenSellOffer]); + if (!isTesSuccess(err2)) + return err2; - if (buy && sell) + if (bo && so) { // Brokered mode: - auto const bo = ctx.view.read(keylet::nftoffer(*buy)); - auto const so = ctx.view.read(keylet::nftoffer(*sell)); - // The two offers being brokered must be for the same token: if ((*bo)[sfNFTokenID] != (*so)[sfNFTokenID]) return tecNFTOKEN_BUY_SELL_MISMATCH; @@ -131,10 +128,8 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) } } - if (buy) + if (bo) { - auto const bo = ctx.view.read(keylet::nftoffer(*buy)); - if (((*bo)[sfFlags] & lsfSellNFToken) == lsfSellNFToken) return tecNFTOKEN_OFFER_TYPE_MISMATCH; @@ -143,7 +138,7 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) return tecCANT_ACCEPT_OWN_NFTOKEN_OFFER; // If not in bridged mode, the account must own the token: - if (!sell && + if (!so && !nft::findToken(ctx.view, ctx.tx[sfAccount], (*bo)[sfNFTokenID])) return tecNO_PERMISSION; @@ -160,10 +155,8 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) return tecINSUFFICIENT_FUNDS; } - if (sell) + if (so) { - auto const so = ctx.view.read(keylet::nftoffer(*sell)); - if (((*so)[sfFlags] & lsfSellNFToken) != lsfSellNFToken) return tecNFTOKEN_OFFER_TYPE_MISMATCH; @@ -176,7 +169,7 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; // If not in bridged mode... - if (!buy) + if (!bo) { // If the offer has a Destination field, the acceptor must be the // Destination. diff --git a/src/ripple/app/tx/impl/NFTokenBurn.cpp b/src/ripple/app/tx/impl/NFTokenBurn.cpp index f1f5ae8a787..da23d78bdbd 100644 --- a/src/ripple/app/tx/impl/NFTokenBurn.cpp +++ b/src/ripple/app/tx/impl/NFTokenBurn.cpp @@ -77,27 +77,9 @@ NFTokenBurn::preclaim(PreclaimContext const& ctx) } } - auto const id = ctx.tx[sfNFTokenID]; - - std::size_t totalOffers = 0; - - { - Dir buys(ctx.view, keylet::nft_buys(id)); - totalOffers += std::distance(buys.begin(), buys.end()); - } - - if (totalOffers > maxDeletableTokenOfferEntries) - return tefTOO_BIG; - - { - Dir sells(ctx.view, keylet::nft_sells(id)); - totalOffers += std::distance(sells.begin(), sells.end()); - } - - if (totalOffers > maxDeletableTokenOfferEntries) - return tefTOO_BIG; - - return tesSUCCESS; + // If there are too many offers, then burning the token would produce too + // much metadata. Disallow burning a token with too many offers. + return nft::notTooManyOffers(ctx.view, ctx.tx[sfNFTokenID]); } TER diff --git a/src/ripple/app/tx/impl/details/NFTokenUtils.cpp b/src/ripple/app/tx/impl/details/NFTokenUtils.cpp index f99c6cf6b17..d1214a98ee8 100644 --- a/src/ripple/app/tx/impl/details/NFTokenUtils.cpp +++ b/src/ripple/app/tx/impl/details/NFTokenUtils.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -131,15 +132,40 @@ getPageForToken( cmp; }); - // If splitIter == begin(), then the entire page is filled with - // equivalent tokens. We cannot split the page, so we cannot - // insert the requested token. - // // There should be no circumstance when splitIter == end(), but if it // were to happen we should bail out because something is confused. - if (splitIter == narr.begin() || splitIter == narr.end()) + if (splitIter == narr.end()) return nullptr; + // If splitIter == begin(), then the entire page is filled with + // equivalent tokens. This requires special handling. + if (splitIter == narr.begin()) + { + // Prior to fixNFTokenDirV1 we simply stopped. + if (!view.rules().enabled(fixNFTokenDirV1)) + return nullptr; + else + { + // This would be an ideal place for the spaceship operator... + int const relation = compare(id & nft::pageMask, cmp); + if (relation == 0) + // If the passed in id belongs exactly on this (full) page + // this account simply cannot store the NFT. + return nullptr; + + else if (relation > 0) + // We need to leave the entire contents of this page in + // narr so carr stays empty. The new NFT will be + // inserted in carr. This keeps the NFTs that must be + // together all on their own page. + splitIter = narr.end(); + + // If neither of those conditions apply then put all of + // narr into carr and produce an empty narr where the new NFT + // will be inserted. Leave the split at narr.begin(). + } + } + // Split narr at splitIter. STArray newCarr( std::make_move_iterator(splitIter), @@ -148,8 +174,20 @@ getPageForToken( std::swap(carr, newCarr); } - auto np = std::make_shared( - keylet::nftpage(base, carr[0].getFieldH256(sfNFTokenID))); + // Determine the ID for the page index. This decision is conditional on + // fixNFTokenDirV1 being enabled. But the condition for the decision + // is not possible unless fixNFTokenDirV1 is enabled. + // + // Note that we use uint256::next() because there's a subtlety in the way + // NFT pages are structured. The low 96-bits of NFT ID must be strictly + // less than the low 96-bits of the enclosing page's index. In order to + // accommodate that requirement we use an index one higher than the + // largest NFT in the page. + uint256 const tokenIDForNewPage = narr.size() == dirMaxTokensPerPage + ? narr[dirMaxTokensPerPage - 1].getFieldH256(sfNFTokenID).next() + : carr[0].getFieldH256(sfNFTokenID); + + auto np = std::make_shared(keylet::nftpage(base, tokenIDForNewPage)); np->setFieldArray(sfNFTokens, narr); np->setFieldH256(sfNextPageMin, cp->key()); @@ -172,10 +210,17 @@ getPageForToken( createCallback(view, owner); - return (first.key <= np->key()) ? np : cp; + // fixNFTokenDirV1 corrects a bug in the initial implementation that + // would put an NFT in the wrong page. The problem was caused by an + // off-by-one subtlety that the NFT can only be stored in the first page + // with a key that's strictly greater than `first` + if (!view.rules().enabled(fixNFTokenDirV1)) + return (first.key <= np->key()) ? np : cp; + + return (first.key < np->key()) ? np : cp; } -static bool +bool compareTokens(uint256 const& a, uint256 const& b) { // The sort of NFTokens needs to be fully deterministic, but the sort @@ -505,6 +550,33 @@ removeAllTokenOffers(ApplyView& view, Keylet const& directory) }); } +TER +notTooManyOffers(ReadView const& view, uint256 const& nftokenID) +{ + std::size_t totalOffers = 0; + + { + Dir buys(view, keylet::nft_buys(nftokenID)); + for (auto iter = buys.begin(); iter != buys.end(); iter.next_page()) + { + totalOffers += iter.page_size(); + if (totalOffers > maxDeletableTokenOfferEntries) + return tefTOO_BIG; + } + } + + { + Dir sells(view, keylet::nft_sells(nftokenID)); + for (auto iter = sells.begin(); iter != sells.end(); iter.next_page()) + { + totalOffers += iter.page_size(); + if (totalOffers > maxDeletableTokenOfferEntries) + return tefTOO_BIG; + } + } + return tesSUCCESS; +} + bool deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer) { diff --git a/src/ripple/app/tx/impl/details/NFTokenUtils.h b/src/ripple/app/tx/impl/details/NFTokenUtils.h index aac5dbf5fa7..fa8c43b5877 100644 --- a/src/ripple/app/tx/impl/details/NFTokenUtils.h +++ b/src/ripple/app/tx/impl/details/NFTokenUtils.h @@ -53,15 +53,14 @@ constexpr std::uint16_t const flagOnlyXRP = 0x0002; constexpr std::uint16_t const flagCreateTrustLines = 0x0004; constexpr std::uint16_t const flagTransferable = 0x0008; -/** Erases the specified offer from the specified token offer directory. - - */ -void -removeTokenOffer(ApplyView& view, uint256 const& id); - +/** Deletes all offers from the specified token offer directory. */ void removeAllTokenOffers(ApplyView& view, Keylet const& directory); +/** Returns tesSUCCESS if NFToken has few enough offers that it can be burned */ +TER +notTooManyOffers(ReadView const& view, uint256 const& nftokenID); + /** Finds the specified token in the owner's token directory. */ std::optional findToken( @@ -179,6 +178,9 @@ getIssuer(uint256 const& id) return AccountID::fromVoid(id.data() + 4); } +bool +compareTokens(uint256 const& a, uint256 const& b); + } // namespace nft } // namespace ripple diff --git a/src/ripple/ledger/Directory.h b/src/ripple/ledger/Directory.h index c24d348e7e8..0efcf43e773 100644 --- a/src/ripple/ledger/Directory.h +++ b/src/ripple/ledger/Directory.h @@ -79,6 +79,12 @@ class Dir::const_iterator const_iterator operator++(int); + const_iterator& + next_page(); + + std::size_t + page_size(); + Keylet const& page() const { diff --git a/src/ripple/ledger/impl/Directory.cpp b/src/ripple/ledger/impl/Directory.cpp index de7ea0d2d88..759b4d71b74 100644 --- a/src/ripple/ledger/impl/Directory.cpp +++ b/src/ripple/ledger/impl/Directory.cpp @@ -1,5 +1,4 @@ -//------------ -//------------------------------------------------------------------ +//------------------------------------------------------------------------------ /* This file is part of rippled: https://github.com/ripple/rippled Copyright (c) 2012, 2015 Ripple Labs Inc. @@ -81,44 +80,55 @@ const_iterator::operator++() if (++it_ != std::end(*indexes_)) { index_ = *it_; + cache_ = std::nullopt; + return *this; + } + + return next_page(); +} + +const_iterator +const_iterator::operator++(int) +{ + assert(index_ != beast::zero); + const_iterator tmp(*this); + ++(*this); + return tmp; +} + +const_iterator& +const_iterator::next_page() +{ + auto const next = sle_->getFieldU64(sfIndexNext); + if (next == 0) + { + page_.key = root_.key; + index_ = beast::zero; } else { - auto const next = sle_->getFieldU64(sfIndexNext); - if (next == 0) + page_ = keylet::page(root_, next); + sle_ = view_->read(page_); + assert(sle_); + indexes_ = &sle_->getFieldV256(sfIndexes); + if (indexes_->empty()) { - page_.key = root_.key; index_ = beast::zero; } else { - page_ = keylet::page(root_, next); - sle_ = view_->read(page_); - assert(sle_); - indexes_ = &sle_->getFieldV256(sfIndexes); - if (indexes_->empty()) - { - index_ = beast::zero; - } - else - { - it_ = std::begin(*indexes_); - index_ = *it_; - } + it_ = std::begin(*indexes_); + index_ = *it_; } } - cache_ = std::nullopt; return *this; } -const_iterator -const_iterator::operator++(int) +std::size_t +const_iterator::page_size() { - assert(index_ != beast::zero); - const_iterator tmp(*this); - ++(*this); - return tmp; + return indexes_->size(); } } // namespace ripple diff --git a/src/ripple/protocol/Feature.h b/src/ripple/protocol/Feature.h index 67991d4ff93..b3ecb099bcc 100644 --- a/src/ripple/protocol/Feature.h +++ b/src/ripple/protocol/Feature.h @@ -74,7 +74,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 47; +static constexpr std::size_t numFeatures = 48; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -335,6 +335,7 @@ extern uint256 const fixRmSmallIncreasedQOffers; extern uint256 const featureCheckCashMakesTrustLine; extern uint256 const featureNonFungibleTokensV1; extern uint256 const featureExpandedSignerList; +extern uint256 const fixNFTokenDirV1; } // namespace ripple diff --git a/src/ripple/protocol/impl/Feature.cpp b/src/ripple/protocol/impl/Feature.cpp index 7b9161c08cd..f6f67c003bc 100644 --- a/src/ripple/protocol/impl/Feature.cpp +++ b/src/ripple/protocol/impl/Feature.cpp @@ -439,6 +439,7 @@ REGISTER_FIX (fixRmSmallIncreasedQOffers, Supported::yes, DefaultVote::yes REGISTER_FEATURE(CheckCashMakesTrustLine, Supported::yes, DefaultVote::no); REGISTER_FEATURE(NonFungibleTokensV1, Supported::yes, DefaultVote::no); REGISTER_FEATURE(ExpandedSignerList, Supported::yes, DefaultVote::no); +REGISTER_FIX (fixNFTokenDirV1, Supported::yes, DefaultVote::no); // The following amendments have been active for at least two years. Their // pre-amendment code has been removed and the identifiers are deprecated. diff --git a/src/ripple/rpc/handlers/AccountObjects.cpp b/src/ripple/rpc/handlers/AccountObjects.cpp index 55fe4e4136b..4dcb3aba7de 100644 --- a/src/ripple/rpc/handlers/AccountObjects.cpp +++ b/src/ripple/rpc/handlers/AccountObjects.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -101,21 +102,41 @@ doAccountNFTs(RPC::JsonContext& context) auto& nfts = (result[jss::account_nfts] = Json::arrayValue); // Continue iteration from the current page: - + bool pastMarker = marker.isZero(); + uint256 const maskedMarker = marker & nft::pageMask; while (cp) { auto arr = cp->getFieldArray(sfNFTokens); for (auto const& o : arr) { - if (o.getFieldH256(sfNFTokenID) <= marker) + // Scrolling past the marker gets weird. We need to look at + // a couple of conditions. + // + // 1. If the low 96-bits don't match, then we compare only + // against the low 96-bits, since that's what determines + // the sort order of the pages. + // + // 2. However, within one page there can be a number of + // NFTokenIDs that all have the same low 96 bits. If we're + // in that case then we need to compare against the full + // 256 bits. + uint256 const nftokenID = o[sfNFTokenID]; + uint256 const maskedNftokenID = nftokenID & nft::pageMask; + + if (!pastMarker && maskedNftokenID < maskedMarker) + continue; + + if (!pastMarker && maskedNftokenID == maskedMarker && + nftokenID <= marker) continue; + pastMarker = true; + { Json::Value& obj = nfts.append(o.getJson(JsonOptions::none)); // Pull out the components of the nft ID. - uint256 const nftokenID = o[sfNFTokenID]; obj[sfFlags.jsonName] = nft::getFlags(nftokenID); obj[sfIssuer.jsonName] = to_string(nft::getIssuer(nftokenID)); obj[sfNFTokenTaxon.jsonName] = diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp index e40f4c839a7..00124731cb9 100644 --- a/src/test/app/NFTokenBurn_test.cpp +++ b/src/test/app/NFTokenBurn_test.cpp @@ -595,8 +595,11 @@ class NFTokenBurn_test : public beast::unit_test::suite run() override { using namespace test::jtx; - auto const sa = supported_amendments(); - testWithFeats(sa); + FeatureBitset const all{supported_amendments()}; + FeatureBitset const fixNFTDir{fixNFTokenDirV1}; + + testWithFeats(all - fixNFTDir); + testWithFeats(all); } }; diff --git a/src/test/app/NFTokenDir_test.cpp b/src/test/app/NFTokenDir_test.cpp index c19a8d0790a..ae7eeeaf603 100644 --- a/src/test/app/NFTokenDir_test.cpp +++ b/src/test/app/NFTokenDir_test.cpp @@ -17,6 +17,7 @@ */ //============================================================================== +#include #include #include #include @@ -95,6 +96,63 @@ class NFTokenDir_test : public beast::unit_test::suite } } + void + testConsecutiveNFTs(FeatureBitset features) + { + // It should be possible to store many consecutive NFTs. + testcase("Sequential NFTs"); + + using namespace test::jtx; + Env env{*this, features}; + + // A single minter tends not to mint numerically sequential NFTokens + // because the taxon cipher mixes things up. We can override the + // cipher, however, and mint many sequential NFTokens with no gaps + // between them. + // + // Here we'll simply mint 100 sequential NFTs. Then we'll create + // offers for them to verify that the ledger can find them. + + Account const issuer{"issuer"}; + Account const buyer{"buyer"}; + env.fund(XRP(10000), buyer, issuer); + env.close(); + + // Mint 100 sequential NFTs. Tweak the taxon so zero is always stored. + // That's what makes them sequential. + constexpr std::size_t nftCount = 100; + std::vector nftIDs; + nftIDs.reserve(nftCount); + for (int i = 0; i < nftCount; ++i) + { + std::uint32_t taxon = + toUInt32(nft::cipheredTaxon(i, nft::toTaxon(0))); + nftIDs.emplace_back( + token::getNextID(env, issuer, taxon, tfTransferable)); + env(token::mint(issuer, taxon), txflags(tfTransferable)); + env.close(); + } + + // Create an offer for each of the NFTs. This verifies that the ledger + // can find all of the minted NFTs. + std::vector offers; + for (uint256 const& nftID : nftIDs) + { + offers.emplace_back(keylet::nftoffer(issuer, env.seq(issuer)).key); + env(token::createOffer(issuer, nftID, XRP(0)), + txflags((tfSellNFToken))); + env.close(); + } + + // Buyer accepts all of the offers in reverse order. + std::reverse(offers.begin(), offers.end()); + for (uint256 const& offer : offers) + { + env(token::acceptSellOffer(buyer, offer)); + env.close(); + } + } + void testLopsidedSplits(FeatureBitset features) { @@ -307,17 +365,224 @@ class NFTokenDir_test : public beast::unit_test::suite "sp6JS7f14BuwFY8Mw6xCigaMwC6Dp", // 32. 0x309b67ed }; - // FUTURE TEST + // Run the test cases. + exerciseLopsided(splitAndAddToHi); + exerciseLopsided(splitAndAddToLo); + } + + void + testFixNFTokenDirV1(FeatureBitset features) + { + // Exercise a fix for an off-by-one in the creation of an NFTokenPage + // index. + testcase("fixNFTokenDirV1"); + + using namespace test::jtx; + + // When a single NFT page exceeds 32 entries, the code is inclined + // to split that page into two equal pieces. The new page is lower + // than the original. There was an off-by-one in the selection of + // the index for the new page. This test recreates the problem. + + // Lambda that exercises the split. + auto exerciseFixNFTokenDirV1 = + [this, + &features](std::initializer_list seeds) { + Env env{*this, features}; + + // Eventually all of the NFTokens will be owned by buyer. + Account const buyer{"buyer"}; + env.fund(XRP(10000), buyer); + env.close(); + + // Create accounts for all of the seeds and fund those accounts. + std::vector accounts; + accounts.reserve(seeds.size()); + for (std::string_view const& seed : seeds) + { + Account const& account = accounts.emplace_back( + Account::base58Seed, std::string(seed)); + env.fund(XRP(10000), account); + env.close(); + } + + // All of the accounts create one NFT and and offer that NFT to + // buyer. + std::vector nftIDs; + std::vector offers; + offers.reserve(accounts.size()); + for (Account const& account : accounts) + { + // Mint the NFT. + uint256 const& nftID = nftIDs.emplace_back( + token::getNextID(env, account, 0, tfTransferable)); + env(token::mint(account, 0), txflags(tfTransferable)); + env.close(); + + // Create an offer to give the NFT to buyer for free. + offers.emplace_back( + keylet::nftoffer(account, env.seq(account)).key); + env(token::createOffer(account, nftID, XRP(0)), + token::destination(buyer), + txflags((tfSellNFToken))); + } + env.close(); + + // buyer accepts all of the but the last. The last offer + // causes the page to split. + for (std::size_t i = 0; i < offers.size() - 1; ++i) + { + env(token::acceptSellOffer(buyer, offers[i])); + env.close(); + } + + // Here is the last offer. Without the fix accepting this + // offer causes tecINVARIANT_FAILED. With the fix the offer + // accept succeeds. + if (!features[fixNFTokenDirV1]) + { + env(token::acceptSellOffer(buyer, offers.back()), + ter(tecINVARIANT_FAILED)); + env.close(); + return; + } + env(token::acceptSellOffer(buyer, offers.back())); + env.close(); + + // This can be a good time to look at the NFT pages. + // printNFTPages(env, noisy); + + // Verify that all NFTs are owned by buyer and findable in the + // ledger by having buyer create sell offers for all of their + // NFTs. Attempting to sell an offer that the ledger can't find + // generates a non-tesSUCCESS error code. + for (uint256 const& nftID : nftIDs) + { + uint256 const offerID = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, XRP(100)), + txflags(tfSellNFToken)); + env.close(); + + env(token::cancelOffer(buyer, {offerID})); + } + + // Verify that all the NFTs are owned by buyer. + Json::Value buyerNFTs = [&env, &buyer]() { + Json::Value params; + params[jss::account] = buyer.human(); + params[jss::type] = "state"; + return env.rpc("json", "account_nfts", to_string(params)); + }(); + + BEAST_EXPECT( + buyerNFTs[jss::result][jss::account_nfts].size() == + nftIDs.size()); + for (Json::Value const& ownedNFT : + buyerNFTs[jss::result][jss::account_nfts]) + { + uint256 ownedID; + BEAST_EXPECT(ownedID.parseHex( + ownedNFT[sfNFTokenID.jsonName].asString())); + auto const foundIter = + std::find(nftIDs.begin(), nftIDs.end(), ownedID); + + // Assuming we find the NFT, erase it so we know it's been + // found and can't be found again. + if (BEAST_EXPECT(foundIter != nftIDs.end())) + nftIDs.erase(foundIter); + } + + // All NFTs should now be accounted for, so nftIDs should be + // empty. + BEAST_EXPECT(nftIDs.empty()); + }; + // These seeds fill the last 17 entries of the initial page with // equivalent NFTs. The split should keep these together. + static std::initializer_list const seventeenHi{ + // These 16 need to be kept together by the implementation. + "sp6JS7f14BuwFY8Mw5EYu5z86hKDL", // 0. 0x399187e9 + "sp6JS7f14BuwFY8Mw5PUAMwc5ygd7", // 1. 0x399187e9 + "sp6JS7f14BuwFY8Mw5R3xUBcLSeTs", // 2. 0x399187e9 + "sp6JS7f14BuwFY8Mw5W6oS5sdC3oF", // 3. 0x399187e9 + "sp6JS7f14BuwFY8Mw5pYc3D9iuLcw", // 4. 0x399187e9 + "sp6JS7f14BuwFY8Mw5pfGVnhcdp3b", // 5. 0x399187e9 + "sp6JS7f14BuwFY8Mw6jS6RdEqXqrN", // 6. 0x399187e9 + "sp6JS7f14BuwFY8Mw6krt6AKbvRXW", // 7. 0x399187e9 + "sp6JS7f14BuwFY8Mw6mnVBQq7cAN2", // 8. 0x399187e9 + "sp6JS7f14BuwFY8Mw8ECJxPjmkufQ", // 9. 0x399187e9 + "sp6JS7f14BuwFY8Mw8asgzcceGWYm", // 10. 0x399187e9 + "sp6JS7f14BuwFY8MwF6J3FXnPCgL8", // 11. 0x399187e9 + "sp6JS7f14BuwFY8MwFEud2w5czv5q", // 12. 0x399187e9 + "sp6JS7f14BuwFY8MwFNxKVqJnx8P5", // 13. 0x399187e9 + "sp6JS7f14BuwFY8MwFnTCXg3eRidL", // 14. 0x399187e9 + "sp6JS7f14BuwFY8Mwj47hv1vrDge6", // 15. 0x399187e9 + + // These 17 need to be kept together by the implementation. + "sp6JS7f14BuwFY8MwjJCwYr9zSfAv", // 16. 0xabb11898 + "sp6JS7f14BuwFY8MwjYa5yLkgCLuT", // 17. 0xabb11898 + "sp6JS7f14BuwFY8MwjenxuJ3TH2Bc", // 18. 0xabb11898 + "sp6JS7f14BuwFY8MwjriN7Ui11NzB", // 19. 0xabb11898 + "sp6JS7f14BuwFY8Mwk3AuoJNSEo34", // 20. 0xabb11898 + "sp6JS7f14BuwFY8MwkT36hnRv8hTo", // 21. 0xabb11898 + "sp6JS7f14BuwFY8MwkTQixEXfi1Cr", // 22. 0xabb11898 + "sp6JS7f14BuwFY8MwkYJaZM1yTJBF", // 23. 0xabb11898 + "sp6JS7f14BuwFY8Mwkc4k1uo85qp2", // 24. 0xabb11898 + "sp6JS7f14BuwFY8Mwkf7cFhF1uuxx", // 25. 0xabb11898 + "sp6JS7f14BuwFY8MwmCK2un99wb4e", // 26. 0xabb11898 + "sp6JS7f14BuwFY8MwmETztNHYu2Bx", // 27. 0xabb11898 + "sp6JS7f14BuwFY8MwmJws9UwRASfR", // 28. 0xabb11898 + "sp6JS7f14BuwFY8MwoH5PQkGK8tEb", // 29. 0xabb11898 + "sp6JS7f14BuwFY8MwoVXtP2yCzjJV", // 30. 0xabb11898 + "sp6JS7f14BuwFY8MwobxRXA9vsTeX", // 31. 0xabb11898 + "sp6JS7f14BuwFY8Mwos3pc5Gb3ihU", // 32. 0xabb11898 + }; - // FUTURE TEST // These seeds fill the first entries of the initial page with // equivalent NFTs. The split should keep these together. + static std::initializer_list const seventeenLo{ + // These 17 need to be kept together by the implementation. + "sp6JS7f14BuwFY8Mw5EYu5z86hKDL", // 0. 0x399187e9 + "sp6JS7f14BuwFY8Mw5PUAMwc5ygd7", // 1. 0x399187e9 + "sp6JS7f14BuwFY8Mw5R3xUBcLSeTs", // 2. 0x399187e9 + "sp6JS7f14BuwFY8Mw5W6oS5sdC3oF", // 3. 0x399187e9 + "sp6JS7f14BuwFY8Mw5pYc3D9iuLcw", // 4. 0x399187e9 + "sp6JS7f14BuwFY8Mw5pfGVnhcdp3b", // 5. 0x399187e9 + "sp6JS7f14BuwFY8Mw6jS6RdEqXqrN", // 6. 0x399187e9 + "sp6JS7f14BuwFY8Mw6krt6AKbvRXW", // 7. 0x399187e9 + "sp6JS7f14BuwFY8Mw6mnVBQq7cAN2", // 8. 0x399187e9 + "sp6JS7f14BuwFY8Mw8ECJxPjmkufQ", // 9. 0x399187e9 + "sp6JS7f14BuwFY8Mw8asgzcceGWYm", // 10. 0x399187e9 + "sp6JS7f14BuwFY8MwF6J3FXnPCgL8", // 11. 0x399187e9 + "sp6JS7f14BuwFY8MwFEud2w5czv5q", // 12. 0x399187e9 + "sp6JS7f14BuwFY8MwFNxKVqJnx8P5", // 13. 0x399187e9 + "sp6JS7f14BuwFY8MwFnTCXg3eRidL", // 14. 0x399187e9 + "sp6JS7f14BuwFY8Mwj47hv1vrDge6", // 15. 0x399187e9 + "sp6JS7f14BuwFY8Mwj6TYekeeyukh", // 16. 0x399187e9 + + // These 16 need to be kept together by the implementation. + "sp6JS7f14BuwFY8MwjYa5yLkgCLuT", // 17. 0xabb11898 + "sp6JS7f14BuwFY8MwjenxuJ3TH2Bc", // 18. 0xabb11898 + "sp6JS7f14BuwFY8MwjriN7Ui11NzB", // 19. 0xabb11898 + "sp6JS7f14BuwFY8Mwk3AuoJNSEo34", // 20. 0xabb11898 + "sp6JS7f14BuwFY8MwkT36hnRv8hTo", // 21. 0xabb11898 + "sp6JS7f14BuwFY8MwkTQixEXfi1Cr", // 22. 0xabb11898 + "sp6JS7f14BuwFY8MwkYJaZM1yTJBF", // 23. 0xabb11898 + "sp6JS7f14BuwFY8Mwkc4k1uo85qp2", // 24. 0xabb11898 + "sp6JS7f14BuwFY8Mwkf7cFhF1uuxx", // 25. 0xabb11898 + "sp6JS7f14BuwFY8MwmCK2un99wb4e", // 26. 0xabb11898 + "sp6JS7f14BuwFY8MwmETztNHYu2Bx", // 27. 0xabb11898 + "sp6JS7f14BuwFY8MwmJws9UwRASfR", // 28. 0xabb11898 + "sp6JS7f14BuwFY8MwoH5PQkGK8tEb", // 29. 0xabb11898 + "sp6JS7f14BuwFY8MwoVXtP2yCzjJV", // 30. 0xabb11898 + "sp6JS7f14BuwFY8MwobxRXA9vsTeX", // 31. 0xabb11898 + "sp6JS7f14BuwFY8Mwos3pc5Gb3ihU", // 32. 0xabb11898 + }; // Run the test cases. - exerciseLopsided(splitAndAddToHi); - exerciseLopsided(splitAndAddToLo); + exerciseFixNFTokenDirV1(seventeenHi); + exerciseFixNFTokenDirV1(seventeenLo); } void @@ -338,23 +603,40 @@ class NFTokenDir_test : public beast::unit_test::suite // Here are 33 seeds that produce identical low 32-bits in their // corresponding AccountIDs. - // - // NOTE: We've not yet identified 33 AccountIDs that meet the - // requirements. At the moment 12 is the best we can do. We'll fill - // in the full count when they are available. static std::initializer_list const seeds{ - "sp6JS7f14BuwFY8Mw5G5vCrbxB3TZ", - "sp6JS7f14BuwFY8Mw5H6qyXhorcip", - "sp6JS7f14BuwFY8Mw5suWxsBQRqLx", - "sp6JS7f14BuwFY8Mw66gtwamvGgSg", - "sp6JS7f14BuwFY8Mw66iNV4PPcmyt", - "sp6JS7f14BuwFY8Mw68Qz2P58ybfE", - "sp6JS7f14BuwFY8Mw6AYtLXKzi2Bo", - "sp6JS7f14BuwFY8Mw6boCES4j62P2", - "sp6JS7f14BuwFY8Mw6kv7QDDv7wjw", - "sp6JS7f14BuwFY8Mw6mHXMvpBjjwg", - "sp6JS7f14BuwFY8Mw6qfGbznyYvVp", - "sp6JS7f14BuwFY8Mw6zg6qHKDfSoU", + "sp6JS7f14BuwFY8Mw5FnqmbciPvH6", // 0. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw5MBGbyMSsXLp", // 1. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw5S4PnDyBdKKm", // 2. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw6kcXpM2enE35", // 3. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw6tuuSMMwyJ44", // 4. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8E8JWLQ1P8pt", // 5. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8WwdgWkCHhEx", // 6. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8XDUYvU6oGhQ", // 7. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8ceVGL4M1zLQ", // 8. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8fdSwLCZWDFd", // 9. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mw8zuF6Fg65i1E", // 10. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwF2k7bihVfqes", // 11. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwF6X24WXGn557", // 12. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwFMpn7strjekg", // 13. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwFSdy9sYVrwJs", // 14. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwFdMcLy9UkrXn", // 15. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwFdbwFm1AAboa", // 16. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwFdr5AhKThVtU", // 17. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwjFc3Q9YatvAw", // 18. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwjRXcNs1ozEXn", // 19. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwkQGUKL7v1FBt", // 20. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mwkamsoxx1wECt", // 21. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mwm3hus1dG6U8y", // 22. 0x9a8ebed3 + "sp6JS7f14BuwFY8Mwm589M8vMRpXF", // 23. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwmJTRJ4Fqz1A3", // 24. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwmRfy8fer4QbL", // 25. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwmkkFx1HtgWRx", // 26. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwmwP9JFdKa4PS", // 27. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwoXWJLB3ciHfo", // 28. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwoYc1gTtT2mWL", // 29. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwogXtHH7FNVoo", // 30. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwoqYoA9P8gf3r", // 31. 0x9a8ebed3 + "sp6JS7f14BuwFY8MwoujwMJofGnsA", // 32. 0x9a8ebed3 }; // Create accounts for all of the seeds and fund those accounts. @@ -396,15 +678,25 @@ class NFTokenDir_test : public beast::unit_test::suite BEAST_EXPECT(expectLowBits == (nftID & nft::pageMask)); } - // buyer accepts all of the offers. + // Remove one NFT and offer from the vectors. This offer is the one + // that will overflow the page. + nftIDs.pop_back(); + uint256 const offerForPageOverflow = offers.back(); + offers.pop_back(); + + // buyer accepts all of the offers but one. for (uint256 const& offer : offers) { env(token::acceptSellOffer(buyer, offer)); env.close(); } - // Verify that all NFTs are owned by buyer and findable in the - // ledger by having buyer create sell offers for all of their NFTs. + // buyer accepts the last offer which causes a page overflow. + env(token::acceptSellOffer(buyer, offerForPageOverflow), + ter(tecNO_SUITABLE_NFTOKEN_PAGE)); + + // Verify that all expected NFTs are owned by buyer and findable in + // the ledger by having buyer create sell offers for all of their NFTs. // Attempting to sell an offer that the ledger can't find generates // a non-tesSUCCESS error code. for (uint256 const& nftID : nftIDs) @@ -444,13 +736,332 @@ class NFTokenDir_test : public beast::unit_test::suite // All NFTs should now be accounted for, so nftIDs should be empty. BEAST_EXPECT(nftIDs.empty()); + + // Show that Without fixNFTokenDirV1 no more NFTs can be added to + // buyer. Also show that fixNFTokenDirV1 fixes the problem. + TER const expect = features[fixNFTokenDirV1] + ? static_cast(tesSUCCESS) + : static_cast(tecNO_SUITABLE_NFTOKEN_PAGE); + env(token::mint(buyer, 0), txflags(tfTransferable), ter(expect)); + env.close(); + } + + void + testConsecutivePacking(FeatureBitset features) + { + // We'll make a worst case scenario for NFT packing: + // + // 1. 33 accounts with identical low-32 bits mint 7 consecutive NFTs. + // 2. The taxon is manipulated to always be stored as zero. + // 3. A single account buys all 7x32 of the 33 NFTs. + // + // All of the NFTs should be acquired by the buyer. + // + // Lastly, none of the remaining NFTs should be acquirable by the + // buyer. They would cause page overflow. + + // This test collapses in a heap if fixNFTokenDirV1 is not enabled. + // If it is enabled just return so we skip the test. + if (!features[fixNFTokenDirV1]) + return; + + testcase("NFToken consecutive packing"); + + using namespace test::jtx; + + Env env{*this, features}; + + // Eventually all of the NFTokens will be owned by buyer. + Account const buyer{"buyer"}; + env.fund(XRP(10000), buyer); + env.close(); + + // Here are 33 seeds that produce identical low 32-bits in their + // corresponding AccountIDs. + static std::initializer_list const seeds{ + "sp6JS7f14BuwFY8Mw56vZeiBuhePx", // 0. 0x115d0525 + "sp6JS7f14BuwFY8Mw5BodF9tGuTUe", // 1. 0x115d0525 + "sp6JS7f14BuwFY8Mw5EnhC1cg84J7", // 2. 0x115d0525 + "sp6JS7f14BuwFY8Mw5P913Cunr2BK", // 3. 0x115d0525 + "sp6JS7f14BuwFY8Mw5Pru7eLo1XzT", // 4. 0x115d0525 + "sp6JS7f14BuwFY8Mw61SLUC8UX2m8", // 5. 0x115d0525 + "sp6JS7f14BuwFY8Mw6AsBF9TpeMpq", // 6. 0x115d0525 + "sp6JS7f14BuwFY8Mw84XqrBZkU2vE", // 7. 0x115d0525 + "sp6JS7f14BuwFY8Mw89oSU6dBk3KB", // 8. 0x115d0525 + "sp6JS7f14BuwFY8Mw89qUKCyDmyzj", // 9. 0x115d0525 + "sp6JS7f14BuwFY8Mw8GfqQ9VRZ8tm", // 10. 0x115d0525 + "sp6JS7f14BuwFY8Mw8LtW3VqrqMks", // 11. 0x115d0525 + "sp6JS7f14BuwFY8Mw8ZrAkJc2sHew", // 12. 0x115d0525 + "sp6JS7f14BuwFY8Mw8jpkYSNrD3ah", // 13. 0x115d0525 + "sp6JS7f14BuwFY8MwF2mshd786m3V", // 14. 0x115d0525 + "sp6JS7f14BuwFY8MwFHfXq9x5NbPY", // 15. 0x115d0525 + "sp6JS7f14BuwFY8MwFrjWq5LAB8NT", // 16. 0x115d0525 + "sp6JS7f14BuwFY8Mwj4asgSh6hQZd", // 17. 0x115d0525 + "sp6JS7f14BuwFY8Mwj7ipFfqBSRrE", // 18. 0x115d0525 + "sp6JS7f14BuwFY8MwjHqtcvGav8uW", // 19. 0x115d0525 + "sp6JS7f14BuwFY8MwjLp4sk5fmzki", // 20. 0x115d0525 + "sp6JS7f14BuwFY8MwjioHuYb3Ytkx", // 21. 0x115d0525 + "sp6JS7f14BuwFY8MwkRjHPXWi7fGN", // 22. 0x115d0525 + "sp6JS7f14BuwFY8MwkdVdPV3LjNN1", // 23. 0x115d0525 + "sp6JS7f14BuwFY8MwkxUtVY5AXZFk", // 24. 0x115d0525 + "sp6JS7f14BuwFY8Mwm4jQzdfTbY9F", // 25. 0x115d0525 + "sp6JS7f14BuwFY8MwmCucYAqNp4iF", // 26. 0x115d0525 + "sp6JS7f14BuwFY8Mwo2bgdFtxBzpF", // 27. 0x115d0525 + "sp6JS7f14BuwFY8MwoGwD7v4U6qBh", // 28. 0x115d0525 + "sp6JS7f14BuwFY8MwoUczqFADMoXi", // 29. 0x115d0525 + "sp6JS7f14BuwFY8MwoY1xZeGd3gAr", // 30. 0x115d0525 + "sp6JS7f14BuwFY8MwomVCbfkv4kYZ", // 31. 0x115d0525 + "sp6JS7f14BuwFY8MwoqbrPSr4z13F", // 32. 0x115d0525 + }; + + // Create accounts for all of the seeds and fund those accounts. + std::vector accounts; + accounts.reserve(seeds.size()); + for (std::string_view const& seed : seeds) + { + Account const& account = + accounts.emplace_back(Account::base58Seed, std::string(seed)); + env.fund(XRP(10000), account); + env.close(); + } + + // All of the accounts create seven consecutive NFTs and and offer + // those NFTs to buyer. + std::array, 7> nftIDsByPage; + for (auto& vec : nftIDsByPage) + vec.reserve(accounts.size()); + std::array, 7> offers; + for (auto& vec : offers) + vec.reserve(accounts.size()); + for (std::size_t i = 0; i < nftIDsByPage.size(); ++i) + { + for (Account const& account : accounts) + { + // Mint the NFT. Tweak the taxon so zero is always stored. + std::uint32_t taxon = + toUInt32(nft::cipheredTaxon(i, nft::toTaxon(0))); + + uint256 const& nftID = nftIDsByPage[i].emplace_back( + token::getNextID(env, account, taxon, tfTransferable)); + env(token::mint(account, taxon), txflags(tfTransferable)); + env.close(); + + // Create an offer to give the NFT to buyer for free. + offers[i].emplace_back( + keylet::nftoffer(account, env.seq(account)).key); + env(token::createOffer(account, nftID, XRP(0)), + token::destination(buyer), + txflags((tfSellNFToken))); + } + } + env.close(); + + // Verify that the low 96 bits of all generated NFTs of the same + // sequence is identical. + for (auto const& vec : nftIDsByPage) + { + uint256 const expectLowBits = vec.front() & nft::pageMask; + for (uint256 const& nftID : vec) + { + BEAST_EXPECT(expectLowBits == (nftID & nft::pageMask)); + } + } + + // Remove one NFT and offer from each of the vectors. These offers + // are the ones that will overflow the page. + std::vector overflowNFTs; + overflowNFTs.reserve(nftIDsByPage.size()); + std::vector overflowOffers; + overflowOffers.reserve(nftIDsByPage.size()); + + for (std::size_t i = 0; i < nftIDsByPage.size(); ++i) + { + overflowNFTs.push_back(nftIDsByPage[i].back()); + nftIDsByPage[i].pop_back(); + BEAST_EXPECT(nftIDsByPage[i].size() == seeds.size() - 1); + + overflowOffers.push_back(offers[i].back()); + offers[i].pop_back(); + BEAST_EXPECT(offers[i].size() == seeds.size() - 1); + } + + // buyer accepts all of the offers that won't cause an overflow. + // Fill the center and outsides first to exercise different boundary + // cases. + for (int i : std::initializer_list{3, 6, 0, 1, 2, 5, 4}) + { + for (uint256 const& offer : offers[i]) + { + env(token::acceptSellOffer(buyer, offer)); + env.close(); + } + } + + // buyer accepts the seven offers that would cause page overflows if + // the transaction succeeded. + for (uint256 const& offer : overflowOffers) + { + env(token::acceptSellOffer(buyer, offer), + ter(tecNO_SUITABLE_NFTOKEN_PAGE)); + env.close(); + } + + // Verify that all expected NFTs are owned by buyer and findable in + // the ledger by having buyer create sell offers for all of their NFTs. + // Attempting to sell an offer that the ledger can't find generates + // a non-tesSUCCESS error code. + for (auto const& vec : nftIDsByPage) + { + for (uint256 const& nftID : vec) + { + env(token::createOffer(buyer, nftID, XRP(100)), + txflags(tfSellNFToken)); + env.close(); + } + } + + // See what the account_objects command does with "nft_offer". + { + Json::Value ownedNftOffers(Json::arrayValue); + std::string marker; + do + { + Json::Value buyerOffers = [&env, &buyer, &marker]() { + Json::Value params; + params[jss::account] = buyer.human(); + params[jss::type] = jss::nft_offer; + + if (!marker.empty()) + params[jss::marker] = marker; + return env.rpc( + "json", "account_objects", to_string(params)); + }(); + + marker.clear(); + if (buyerOffers.isMember(jss::result)) + { + Json::Value& result = buyerOffers[jss::result]; + + if (result.isMember(jss::marker)) + marker = result[jss::marker].asString(); + + if (result.isMember(jss::account_objects)) + { + Json::Value& someOffers = result[jss::account_objects]; + for (std::size_t i = 0; i < someOffers.size(); ++i) + ownedNftOffers.append(someOffers[i]); + } + } + } while (!marker.empty()); + + // Verify there are as many offers are there are NFTs. + { + std::size_t totalOwnedNFTs = 0; + for (auto const& vec : nftIDsByPage) + totalOwnedNFTs += vec.size(); + BEAST_EXPECT(ownedNftOffers.size() == totalOwnedNFTs); + } + + // Cancel all the offers. + { + std::vector cancelOffers; + cancelOffers.reserve(ownedNftOffers.size()); + + for (auto const& offer : ownedNftOffers) + { + if (offer.isMember(jss::index)) + { + uint256 offerIndex; + if (offerIndex.parseHex(offer[jss::index].asString())) + cancelOffers.push_back(offerIndex); + } + } + env(token::cancelOffer(buyer, cancelOffers)); + env.close(); + } + + // account_objects should no longer return any "nft_offer"s. + Json::Value remainingOffers = [&env, &buyer]() { + Json::Value params; + params[jss::account] = buyer.human(); + params[jss::type] = jss::nft_offer; + + return env.rpc("json", "account_objects", to_string(params)); + }(); + BEAST_EXPECT( + remainingOffers.isMember(jss::result) && + remainingOffers[jss::result].isMember(jss::account_objects) && + remainingOffers[jss::result][jss::account_objects].size() == 0); + } + + // Verify that the ledger reports all of the NFTs owned by buyer. + // Use the account_nfts rpc call to get the values. + Json::Value ownedNFTs(Json::arrayValue); + std::string marker; + do + { + Json::Value buyerNFTs = [&env, &buyer, &marker]() { + Json::Value params; + params[jss::account] = buyer.human(); + params[jss::type] = "state"; + + if (!marker.empty()) + params[jss::marker] = marker; + return env.rpc("json", "account_nfts", to_string(params)); + }(); + + marker.clear(); + if (buyerNFTs.isMember(jss::result)) + { + Json::Value& result = buyerNFTs[jss::result]; + + if (result.isMember(jss::marker)) + marker = result[jss::marker].asString(); + + if (result.isMember(jss::account_nfts)) + { + Json::Value& someNFTs = result[jss::account_nfts]; + for (std::size_t i = 0; i < someNFTs.size(); ++i) + ownedNFTs.append(someNFTs[i]); + } + } + } while (!marker.empty()); + + // Copy all of the nftIDs into a set to make validation easier. + std::set allNftIDs; + for (auto& vec : nftIDsByPage) + allNftIDs.insert(vec.begin(), vec.end()); + + BEAST_EXPECT(ownedNFTs.size() == allNftIDs.size()); + + for (Json::Value const& ownedNFT : ownedNFTs) + { + if (ownedNFT.isMember(sfNFTokenID.jsonName)) + { + uint256 ownedID; + BEAST_EXPECT(ownedID.parseHex( + ownedNFT[sfNFTokenID.jsonName].asString())); + auto const foundIter = allNftIDs.find(ownedID); + + // Assuming we find the NFT, erase it so we know it's been found + // and can't be found again. + if (BEAST_EXPECT(foundIter != allNftIDs.end())) + allNftIDs.erase(foundIter); + } + } + + // All NFTs should now be accounted for, so allNftIDs should be empty. + BEAST_EXPECT(allNftIDs.empty()); } void testWithFeats(FeatureBitset features) { + testConsecutiveNFTs(features); testLopsidedSplits(features); + testFixNFTokenDirV1(features); testTooManyEquivalent(features); + testConsecutivePacking(features); } public: @@ -458,11 +1069,525 @@ class NFTokenDir_test : public beast::unit_test::suite run() override { using namespace test::jtx; - auto const sa = supported_amendments(); - testWithFeats(sa); + FeatureBitset const all{supported_amendments()}; + FeatureBitset const fixNFTDir{fixNFTokenDirV1}; + + testWithFeats(all - fixNFTDir); + testWithFeats(all); } }; BEAST_DEFINE_TESTSUITE_PRIO(NFTokenDir, tx, ripple, 1); } // namespace ripple + +// Seed that produces an account with the low-32 bits == 0xFFFFFFFF in +// case it is needed for future testing: +// +// sp6JS7f14BuwFY8MwFe95Vpi9Znjs +// + +// Sets of related accounts. +// +// Identifying the seeds of accounts that generate account IDs with the +// same low 32 bits takes a while. However several sets of accounts with +// that relationship have been located. In case these sets of accounts are +// needed for future testing scenarios they are recorded below. +#if 0 +34 account seeds that produce account IDs with low 32-bits 0x399187e9: + sp6JS7f14BuwFY8Mw5EYu5z86hKDL + sp6JS7f14BuwFY8Mw5PUAMwc5ygd7 + sp6JS7f14BuwFY8Mw5R3xUBcLSeTs + sp6JS7f14BuwFY8Mw5W6oS5sdC3oF + sp6JS7f14BuwFY8Mw5pYc3D9iuLcw + sp6JS7f14BuwFY8Mw5pfGVnhcdp3b + sp6JS7f14BuwFY8Mw6jS6RdEqXqrN + sp6JS7f14BuwFY8Mw6krt6AKbvRXW + sp6JS7f14BuwFY8Mw6mnVBQq7cAN2 + sp6JS7f14BuwFY8Mw8ECJxPjmkufQ + sp6JS7f14BuwFY8Mw8asgzcceGWYm + sp6JS7f14BuwFY8MwF6J3FXnPCgL8 + sp6JS7f14BuwFY8MwFEud2w5czv5q + sp6JS7f14BuwFY8MwFNxKVqJnx8P5 + sp6JS7f14BuwFY8MwFnTCXg3eRidL + sp6JS7f14BuwFY8Mwj47hv1vrDge6 + sp6JS7f14BuwFY8Mwj6TYekeeyukh + sp6JS7f14BuwFY8MwjFjsRDerz7jb + sp6JS7f14BuwFY8Mwjrj9mHTLBrcX + sp6JS7f14BuwFY8MwkKcJi3zMzAea + sp6JS7f14BuwFY8MwkYTDdnYRm9z4 + sp6JS7f14BuwFY8Mwkq8ei4D8uPNd + sp6JS7f14BuwFY8Mwm2pFruxbnJRd + sp6JS7f14BuwFY8MwmJV2ZnAjpC2g + sp6JS7f14BuwFY8MwmTFMPHQHfVYF + sp6JS7f14BuwFY8MwmkG2jXEgqiud + sp6JS7f14BuwFY8Mwms3xEh5tMDTw + sp6JS7f14BuwFY8MwmtipW4D8giZ9 + sp6JS7f14BuwFY8MwoRQBZm4KUUeE + sp6JS7f14BuwFY8MwoVey94QpXcrc + sp6JS7f14BuwFY8MwoZiuUoUTo3VG + sp6JS7f14BuwFY8MwonFFDLT4bHAZ + sp6JS7f14BuwFY8MwooGphD4hefBQ + sp6JS7f14BuwFY8MwoxDp3dmX6q5N + +34 account seeds that produce account IDs with low 32-bits 0x473f2c9a: + sp6JS7f14BuwFY8Mw53ktgqmv5Bmz + sp6JS7f14BuwFY8Mw5KPb2Kz7APFX + sp6JS7f14BuwFY8Mw5Xx4A6HRTPEE + sp6JS7f14BuwFY8Mw5y6qZFNAo358 + sp6JS7f14BuwFY8Mw6kdaBg1QrZfn + sp6JS7f14BuwFY8Mw8QmTfLMAZ5K1 + sp6JS7f14BuwFY8Mw8cbRRVcCEELr + sp6JS7f14BuwFY8Mw8gQvJebmxvDG + sp6JS7f14BuwFY8Mw8qPQurwu3P7Y + sp6JS7f14BuwFY8MwFS4PEVKmuPy5 + sp6JS7f14BuwFY8MwFUQM1rAsQ8tS + sp6JS7f14BuwFY8MwjJBZCkuwsRnM + sp6JS7f14BuwFY8MwjTdS8vZhX5E9 + sp6JS7f14BuwFY8MwjhSmWCbNhd25 + sp6JS7f14BuwFY8MwjwkpqwZsDBw9 + sp6JS7f14BuwFY8MwjyET4p6eqd5J + sp6JS7f14BuwFY8MwkMNAe4JhnG7E + sp6JS7f14BuwFY8MwkRRpnT93UWWS + sp6JS7f14BuwFY8MwkY9CvB22RvUe + sp6JS7f14BuwFY8Mwkhw9VxXqmTr7 + sp6JS7f14BuwFY8MwkmgaTat7eFa7 + sp6JS7f14BuwFY8Mwkq5SxGGv1oLH + sp6JS7f14BuwFY8MwmCBM5p5bTg6y + sp6JS7f14BuwFY8MwmmmXaVah64dB + sp6JS7f14BuwFY8Mwo7R7Cn614v9V + sp6JS7f14BuwFY8MwoCAG1na7GR2M + sp6JS7f14BuwFY8MwoDuPvJS4gG7C + sp6JS7f14BuwFY8MwoMMowSyPQLfy + sp6JS7f14BuwFY8MwoRqDiwTNsTBm + sp6JS7f14BuwFY8MwoWbBWtjpB7pg + sp6JS7f14BuwFY8Mwoi1AEeELGecF + sp6JS7f14BuwFY8MwopGP6Lo5byuj + sp6JS7f14BuwFY8MwoufkXGHp2VW8 + sp6JS7f14BuwFY8MwowGeagFQY32k + +34 account seeds that produce account IDs with low 32-bits 0x4d59f0d1: + sp6JS7f14BuwFY8Mw5CsNgH64zxK7 + sp6JS7f14BuwFY8Mw5Dg4wi2E344h + sp6JS7f14BuwFY8Mw5ErV949Zh2PX + sp6JS7f14BuwFY8Mw5p4nsQvEUE1s + sp6JS7f14BuwFY8Mw8LGnkbaP68Gn + sp6JS7f14BuwFY8Mw8aq6RCBc3iHo + sp6JS7f14BuwFY8Mw8bkWaGoKYT6e + sp6JS7f14BuwFY8Mw8qrCuXnzAXVj + sp6JS7f14BuwFY8MwFDKcPAHPHJTm + sp6JS7f14BuwFY8MwFUXJs4unfgNu + sp6JS7f14BuwFY8MwFj9Yv5LjshD9 + sp6JS7f14BuwFY8Mwj3H73nmq5UaC + sp6JS7f14BuwFY8MwjHSYShis1Yhk + sp6JS7f14BuwFY8MwjpfE1HVo8UP1 + sp6JS7f14BuwFY8Mwk6JE1SXUuiNc + sp6JS7f14BuwFY8MwkASgxEjEnFmU + sp6JS7f14BuwFY8MwkGNY8kg7R6RK + sp6JS7f14BuwFY8MwkHinNZ8SYBQu + sp6JS7f14BuwFY8MwkXLCW1hbhGya + sp6JS7f14BuwFY8MwkZ7mWrYK9YtU + sp6JS7f14BuwFY8MwkdFSqNB5DbKL + sp6JS7f14BuwFY8Mwm3jdBaCAx8H6 + sp6JS7f14BuwFY8Mwm3rk5hEwDRtY + sp6JS7f14BuwFY8Mwm77a2ULuwxu4 + sp6JS7f14BuwFY8MwmJpY7braKLaN + sp6JS7f14BuwFY8MwmKHQjG4XiZ6g + sp6JS7f14BuwFY8Mwmmv8Y3wyUDzs + sp6JS7f14BuwFY8MwmucFe1WgqtwG + sp6JS7f14BuwFY8Mwo1EjdU1bznZR + sp6JS7f14BuwFY8MwoJiqankkU5uR + sp6JS7f14BuwFY8MwoLnvQ6zdqbKw + sp6JS7f14BuwFY8MwoUGeJ319eu48 + sp6JS7f14BuwFY8MwoYf135tQjHP4 + sp6JS7f14BuwFY8MwogeF6M6SAyid + +34 account seeds that produce account IDs with low 32-bits 0xabb11898: + sp6JS7f14BuwFY8Mw5DgiYaNVSb1G + sp6JS7f14BuwFY8Mw5k6e94TMvuox + sp6JS7f14BuwFY8Mw5tTSN7KzYxiT + sp6JS7f14BuwFY8Mw61XV6m33utif + sp6JS7f14BuwFY8Mw87jKfrjiENCb + sp6JS7f14BuwFY8Mw8AFtxxFiRtJG + sp6JS7f14BuwFY8Mw8cosAVExzbeE + sp6JS7f14BuwFY8Mw8fmkQ63zE8WQ + sp6JS7f14BuwFY8Mw8iYSsxNbDN6D + sp6JS7f14BuwFY8Mw8wTZdGRJyyM1 + sp6JS7f14BuwFY8Mw8z7xEh3qBGr7 + sp6JS7f14BuwFY8MwFL5gpKQWZj7g + sp6JS7f14BuwFY8MwFPeZchXQnRZ5 + sp6JS7f14BuwFY8MwFSPxWSJVoU29 + sp6JS7f14BuwFY8MwFYyVkqX8kvRm + sp6JS7f14BuwFY8MwFcbVikUEwJvk + sp6JS7f14BuwFY8MwjF7NcZk1NctK + sp6JS7f14BuwFY8MwjJCwYr9zSfAv + sp6JS7f14BuwFY8MwjYa5yLkgCLuT + sp6JS7f14BuwFY8MwjenxuJ3TH2Bc + sp6JS7f14BuwFY8MwjriN7Ui11NzB + sp6JS7f14BuwFY8Mwk3AuoJNSEo34 + sp6JS7f14BuwFY8MwkT36hnRv8hTo + sp6JS7f14BuwFY8MwkTQixEXfi1Cr + sp6JS7f14BuwFY8MwkYJaZM1yTJBF + sp6JS7f14BuwFY8Mwkc4k1uo85qp2 + sp6JS7f14BuwFY8Mwkf7cFhF1uuxx + sp6JS7f14BuwFY8MwmCK2un99wb4e + sp6JS7f14BuwFY8MwmETztNHYu2Bx + sp6JS7f14BuwFY8MwmJws9UwRASfR + sp6JS7f14BuwFY8MwoH5PQkGK8tEb + sp6JS7f14BuwFY8MwoVXtP2yCzjJV + sp6JS7f14BuwFY8MwobxRXA9vsTeX + sp6JS7f14BuwFY8Mwos3pc5Gb3ihU + +34 account seeds that produce account IDs with low 32-bits 0xce627322: + sp6JS7f14BuwFY8Mw5Ck6i83pGNh3 + sp6JS7f14BuwFY8Mw5FKuwTxjAdH1 + sp6JS7f14BuwFY8Mw5FVKkEn6TkLH + sp6JS7f14BuwFY8Mw5NbQwLwHDd5v + sp6JS7f14BuwFY8Mw5X1dbz3msZaZ + sp6JS7f14BuwFY8Mw6qv6qaXNeP74 + sp6JS7f14BuwFY8Mw81SXagUeutCw + sp6JS7f14BuwFY8Mw84Ph7Qa8kwwk + sp6JS7f14BuwFY8Mw8Hp4gFyU3Qko + sp6JS7f14BuwFY8Mw8Kt8bAKredSx + sp6JS7f14BuwFY8Mw8XHK3VKRQ7v7 + sp6JS7f14BuwFY8Mw8eGyWxZGHY6v + sp6JS7f14BuwFY8Mw8iU5CLyHVcD2 + sp6JS7f14BuwFY8Mw8u3Zr26Ar914 + sp6JS7f14BuwFY8MwF2Kcdxtjzjv8 + sp6JS7f14BuwFY8MwFLmPWb6rbxNg + sp6JS7f14BuwFY8MwFUu8s7UVuxuJ + sp6JS7f14BuwFY8MwFYBaatwHxAJ8 + sp6JS7f14BuwFY8Mwjg6hFkeHwoqG + sp6JS7f14BuwFY8MwjjycJojy2ufk + sp6JS7f14BuwFY8MwkEWoxcSKGPXv + sp6JS7f14BuwFY8MwkMe7wLkEUsQT + sp6JS7f14BuwFY8MwkvyKLaPUc4FS + sp6JS7f14BuwFY8Mwm8doqXPKZmVQ + sp6JS7f14BuwFY8Mwm9r3No8yQ8Tx + sp6JS7f14BuwFY8Mwm9w6dks68W9B + sp6JS7f14BuwFY8MwmMPrv9sCdbpS + sp6JS7f14BuwFY8MwmPAvs3fcQNja + sp6JS7f14BuwFY8MwmS5jasapfcnJ + sp6JS7f14BuwFY8MwmU2L3qJEhnuA + sp6JS7f14BuwFY8MwoAQYmiBnW7fM + sp6JS7f14BuwFY8MwoBkkkXrPmkKF + sp6JS7f14BuwFY8MwonfmxPo6tkvC + sp6JS7f14BuwFY8MwouZFwhiNcYq6 + +34 account seeds that produce account IDs with low 32-bits 0xe29643e8: + sp6JS7f14BuwFY8Mw5EfAavcXAh2k + sp6JS7f14BuwFY8Mw5LhFjLkFSCVF + sp6JS7f14BuwFY8Mw5bRfEv5HgdBh + sp6JS7f14BuwFY8Mw5d6sPcKzypKN + sp6JS7f14BuwFY8Mw5rcqDtk1fACP + sp6JS7f14BuwFY8Mw5xkxRq1Notzv + sp6JS7f14BuwFY8Mw66fbkdw5WYmt + sp6JS7f14BuwFY8Mw6diEG8sZ7Fx7 + sp6JS7f14BuwFY8Mw6v2r1QhG7xc1 + sp6JS7f14BuwFY8Mw6zP6DHCTx2Fd + sp6JS7f14BuwFY8Mw8B3n39JKuFkk + sp6JS7f14BuwFY8Mw8FmBvqYw7uqn + sp6JS7f14BuwFY8Mw8KEaftb1eRwu + sp6JS7f14BuwFY8Mw8WJ1qKkegj9N + sp6JS7f14BuwFY8Mw8r8cAZEkq2BS + sp6JS7f14BuwFY8MwFKPxxwF65gZh + sp6JS7f14BuwFY8MwFKhaF8APcN5H + sp6JS7f14BuwFY8MwFN2buJn4BgYC + sp6JS7f14BuwFY8MwFUTe175MjP3x + sp6JS7f14BuwFY8MwFZhmRDb53NNb + sp6JS7f14BuwFY8MwFa2Azn5nU2WS + sp6JS7f14BuwFY8MwjNNt91hwgkn7 + sp6JS7f14BuwFY8MwjdiYt6ChACe7 + sp6JS7f14BuwFY8Mwk5qFVQ48Mmr9 + sp6JS7f14BuwFY8MwkGvCj7pNf1zG + sp6JS7f14BuwFY8MwkY9UcN2D2Fzs + sp6JS7f14BuwFY8MwkpGvSk9G9RyT + sp6JS7f14BuwFY8MwmGQ7nJf1eEzV + sp6JS7f14BuwFY8MwmQLjGsYdyAmV + sp6JS7f14BuwFY8MwmZ8usztKvikT + sp6JS7f14BuwFY8MwobyMLC2hQdFR + sp6JS7f14BuwFY8MwoiRtwUecZeJ5 + sp6JS7f14BuwFY8MwojHjKsUzj1KJ + sp6JS7f14BuwFY8Mwop29anGAjidU + +33 account seeds that produce account IDs with low 32-bits 0x115d0525: + sp6JS7f14BuwFY8Mw56vZeiBuhePx + sp6JS7f14BuwFY8Mw5BodF9tGuTUe + sp6JS7f14BuwFY8Mw5EnhC1cg84J7 + sp6JS7f14BuwFY8Mw5P913Cunr2BK + sp6JS7f14BuwFY8Mw5Pru7eLo1XzT + sp6JS7f14BuwFY8Mw61SLUC8UX2m8 + sp6JS7f14BuwFY8Mw6AsBF9TpeMpq + sp6JS7f14BuwFY8Mw84XqrBZkU2vE + sp6JS7f14BuwFY8Mw89oSU6dBk3KB + sp6JS7f14BuwFY8Mw89qUKCyDmyzj + sp6JS7f14BuwFY8Mw8GfqQ9VRZ8tm + sp6JS7f14BuwFY8Mw8LtW3VqrqMks + sp6JS7f14BuwFY8Mw8ZrAkJc2sHew + sp6JS7f14BuwFY8Mw8jpkYSNrD3ah + sp6JS7f14BuwFY8MwF2mshd786m3V + sp6JS7f14BuwFY8MwFHfXq9x5NbPY + sp6JS7f14BuwFY8MwFrjWq5LAB8NT + sp6JS7f14BuwFY8Mwj4asgSh6hQZd + sp6JS7f14BuwFY8Mwj7ipFfqBSRrE + sp6JS7f14BuwFY8MwjHqtcvGav8uW + sp6JS7f14BuwFY8MwjLp4sk5fmzki + sp6JS7f14BuwFY8MwjioHuYb3Ytkx + sp6JS7f14BuwFY8MwkRjHPXWi7fGN + sp6JS7f14BuwFY8MwkdVdPV3LjNN1 + sp6JS7f14BuwFY8MwkxUtVY5AXZFk + sp6JS7f14BuwFY8Mwm4jQzdfTbY9F + sp6JS7f14BuwFY8MwmCucYAqNp4iF + sp6JS7f14BuwFY8Mwo2bgdFtxBzpF + sp6JS7f14BuwFY8MwoGwD7v4U6qBh + sp6JS7f14BuwFY8MwoUczqFADMoXi + sp6JS7f14BuwFY8MwoY1xZeGd3gAr + sp6JS7f14BuwFY8MwomVCbfkv4kYZ + sp6JS7f14BuwFY8MwoqbrPSr4z13F + +33 account seeds that produce account IDs with low 32-bits 0x304033aa: + sp6JS7f14BuwFY8Mw5DaUP9agF5e1 + sp6JS7f14BuwFY8Mw5ohbtmPN4yGN + sp6JS7f14BuwFY8Mw5rRsA5fcoTAQ + sp6JS7f14BuwFY8Mw6zpYHMY3m6KT + sp6JS7f14BuwFY8Mw86BzQq4sTnoW + sp6JS7f14BuwFY8Mw8CCpnfvmGdV7 + sp6JS7f14BuwFY8Mw8DRjUDaBcFco + sp6JS7f14BuwFY8Mw8cL7GPo3zZN7 + sp6JS7f14BuwFY8Mw8y6aeYVtH6qt + sp6JS7f14BuwFY8MwFZR3PtVTCdUH + sp6JS7f14BuwFY8MwFcdcdbgz7m3s + sp6JS7f14BuwFY8MwjdnJDiUxEBRR + sp6JS7f14BuwFY8MwjhxWgSntqrFe + sp6JS7f14BuwFY8MwjrSHEhZ8CUM1 + sp6JS7f14BuwFY8MwjzkEeSTc9ZYf + sp6JS7f14BuwFY8MwkBZSk9JhaeCB + sp6JS7f14BuwFY8MwkGfwNY4i2iiU + sp6JS7f14BuwFY8MwknjtZd2oU2Ff + sp6JS7f14BuwFY8Mwkszsqd3ok9NE + sp6JS7f14BuwFY8Mwm58A81MAMvgZ + sp6JS7f14BuwFY8MwmiPTWysuDJCH + sp6JS7f14BuwFY8MwmxhiNeLfD76r + sp6JS7f14BuwFY8Mwo7SPdkwpGrFH + sp6JS7f14BuwFY8MwoANq4F1Sj3qH + sp6JS7f14BuwFY8MwoVjcHufAkd6L + sp6JS7f14BuwFY8MwoVxHBXdaxzhm + sp6JS7f14BuwFY8MwoZ2oTjBNfLpm + sp6JS7f14BuwFY8Mwoc9swzyotFVD + sp6JS7f14BuwFY8MwogMqVRwVEcQ9 + sp6JS7f14BuwFY8MwohMm7WxwnFqH + sp6JS7f14BuwFY8MwopUcpZHuF8BH + sp6JS7f14BuwFY8Mwor6rW6SS7tiB + sp6JS7f14BuwFY8MwoxyaqYz4Ngsb + +33 account seeds that produce account IDs with low 32-bits 0x42d4e09c: + sp6JS7f14BuwFY8Mw58NSZH9EaUxQ + sp6JS7f14BuwFY8Mw5JByk1pgPpL7 + sp6JS7f14BuwFY8Mw5YrJJuXnkHVB + sp6JS7f14BuwFY8Mw5kZe2ZzNSnKR + sp6JS7f14BuwFY8Mw6eXHTsbwi1U7 + sp6JS7f14BuwFY8Mw6gqN7HHDDKSh + sp6JS7f14BuwFY8Mw6zw8L1sSSR53 + sp6JS7f14BuwFY8Mw8E4WqSKKbksy + sp6JS7f14BuwFY8MwF3V9gemqJtND + sp6JS7f14BuwFY8Mwj4j46LHWZuY6 + sp6JS7f14BuwFY8MwjF5i8vh4Ezjy + sp6JS7f14BuwFY8MwjJZpEKgMpUAt + sp6JS7f14BuwFY8MwjWL7LfnzNUuh + sp6JS7f14BuwFY8Mwk7Y1csGuqAhX + sp6JS7f14BuwFY8MwkB1HVH17hN5W + sp6JS7f14BuwFY8MwkBntH7BZZupu + sp6JS7f14BuwFY8MwkEy4rMbNHG9P + sp6JS7f14BuwFY8MwkKz4LYesZeiN + sp6JS7f14BuwFY8MwkUrXyo9gMDPM + sp6JS7f14BuwFY8MwkV2hySsxej1G + sp6JS7f14BuwFY8MwkozhTVN12F9C + sp6JS7f14BuwFY8MwkpkzGB3sFJw5 + sp6JS7f14BuwFY8Mwks3zDZLGrhdn + sp6JS7f14BuwFY8MwktG1KCS7L2wW + sp6JS7f14BuwFY8Mwm1jVFsafwcYx + sp6JS7f14BuwFY8Mwm8hmrU6g5Wd6 + sp6JS7f14BuwFY8MwmFvstfRF7e2f + sp6JS7f14BuwFY8MwmeRohi6m5fs8 + sp6JS7f14BuwFY8MwmmU96RHUaRZL + sp6JS7f14BuwFY8MwoDFzteYqaUh4 + sp6JS7f14BuwFY8MwoPkTf5tDykPF + sp6JS7f14BuwFY8MwoSbMaDtiMoDN + sp6JS7f14BuwFY8MwoVL1vY1CysjR + +33 account seeds that produce account IDs with low 32-bits 0x9a8ebed3: + sp6JS7f14BuwFY8Mw5FnqmbciPvH6 + sp6JS7f14BuwFY8Mw5MBGbyMSsXLp + sp6JS7f14BuwFY8Mw5S4PnDyBdKKm + sp6JS7f14BuwFY8Mw6kcXpM2enE35 + sp6JS7f14BuwFY8Mw6tuuSMMwyJ44 + sp6JS7f14BuwFY8Mw8E8JWLQ1P8pt + sp6JS7f14BuwFY8Mw8WwdgWkCHhEx + sp6JS7f14BuwFY8Mw8XDUYvU6oGhQ + sp6JS7f14BuwFY8Mw8ceVGL4M1zLQ + sp6JS7f14BuwFY8Mw8fdSwLCZWDFd + sp6JS7f14BuwFY8Mw8zuF6Fg65i1E + sp6JS7f14BuwFY8MwF2k7bihVfqes + sp6JS7f14BuwFY8MwF6X24WXGn557 + sp6JS7f14BuwFY8MwFMpn7strjekg + sp6JS7f14BuwFY8MwFSdy9sYVrwJs + sp6JS7f14BuwFY8MwFdMcLy9UkrXn + sp6JS7f14BuwFY8MwFdbwFm1AAboa + sp6JS7f14BuwFY8MwFdr5AhKThVtU + sp6JS7f14BuwFY8MwjFc3Q9YatvAw + sp6JS7f14BuwFY8MwjRXcNs1ozEXn + sp6JS7f14BuwFY8MwkQGUKL7v1FBt + sp6JS7f14BuwFY8Mwkamsoxx1wECt + sp6JS7f14BuwFY8Mwm3hus1dG6U8y + sp6JS7f14BuwFY8Mwm589M8vMRpXF + sp6JS7f14BuwFY8MwmJTRJ4Fqz1A3 + sp6JS7f14BuwFY8MwmRfy8fer4QbL + sp6JS7f14BuwFY8MwmkkFx1HtgWRx + sp6JS7f14BuwFY8MwmwP9JFdKa4PS + sp6JS7f14BuwFY8MwoXWJLB3ciHfo + sp6JS7f14BuwFY8MwoYc1gTtT2mWL + sp6JS7f14BuwFY8MwogXtHH7FNVoo + sp6JS7f14BuwFY8MwoqYoA9P8gf3r + sp6JS7f14BuwFY8MwoujwMJofGnsA + +33 account seeds that produce account IDs with low 32-bits 0xa1dcea4a: + sp6JS7f14BuwFY8Mw5Ccov2N36QTy + sp6JS7f14BuwFY8Mw5CuSemVb5p7w + sp6JS7f14BuwFY8Mw5Ep8wpsTfpSz + sp6JS7f14BuwFY8Mw5WtutJc2H45M + sp6JS7f14BuwFY8Mw6vsDeaSKeUJZ + sp6JS7f14BuwFY8Mw83t5BPWUAzzF + sp6JS7f14BuwFY8Mw8FYGnK35mgkV + sp6JS7f14BuwFY8Mw8huo1x5pfKKJ + sp6JS7f14BuwFY8Mw8mPStxfMDrZa + sp6JS7f14BuwFY8Mw8yC3A7aQJytK + sp6JS7f14BuwFY8MwFCWCDmo9o3t8 + sp6JS7f14BuwFY8MwFjapa4gKxPhR + sp6JS7f14BuwFY8Mwj8CWtG29uw71 + sp6JS7f14BuwFY8MwjHyU5KpEMLVT + sp6JS7f14BuwFY8MwjMZSN7LZuWD8 + sp6JS7f14BuwFY8Mwja2TXJNBhKHU + sp6JS7f14BuwFY8Mwjf3xNTopHKTF + sp6JS7f14BuwFY8Mwjn5RAhedPeuM + sp6JS7f14BuwFY8MwkJdr4d6QoE8K + sp6JS7f14BuwFY8MwkmBryo3SUoLm + sp6JS7f14BuwFY8MwkrPdsc4tR8yw + sp6JS7f14BuwFY8Mwkttjcw2a65Fi + sp6JS7f14BuwFY8Mwm19n3rSaNx5S + sp6JS7f14BuwFY8Mwm3ryr4Xp2aQX + sp6JS7f14BuwFY8MwmBnDmgnJLB6B + sp6JS7f14BuwFY8MwmHgPjzrYjthq + sp6JS7f14BuwFY8MwmeV55DAnWKdd + sp6JS7f14BuwFY8Mwo49hK6BGrauT + sp6JS7f14BuwFY8Mwo56vfKY9aoWu + sp6JS7f14BuwFY8MwoU7tTTXLQTrh + sp6JS7f14BuwFY8MwoXpogSF2KaZB + sp6JS7f14BuwFY8MwoY9JYQAR16pc + sp6JS7f14BuwFY8MwoozLzKNAEXKM + +33 account seeds that produce account IDs with low 32-bits 0xbd2116db: + sp6JS7f14BuwFY8Mw5GrpkmPuA3Bw + sp6JS7f14BuwFY8Mw5r1sLoQJZDc6 + sp6JS7f14BuwFY8Mw68zzRmezLdd6 + sp6JS7f14BuwFY8Mw6jDSyaiF1mRp + sp6JS7f14BuwFY8Mw813wU9u5D6Uh + sp6JS7f14BuwFY8Mw8BBvpf2JFGoJ + sp6JS7f14BuwFY8Mw8F7zXxAiT263 + sp6JS7f14BuwFY8Mw8XG7WuVGHP2N + sp6JS7f14BuwFY8Mw8eyWrcz91cz6 + sp6JS7f14BuwFY8Mw8yNVKFVYyk9u + sp6JS7f14BuwFY8MwF2oA6ePqvZWP + sp6JS7f14BuwFY8MwF9VkcSNh3keq + sp6JS7f14BuwFY8MwFYsMWajgEf2j + sp6JS7f14BuwFY8Mwj3Gu43jYoJ4n + sp6JS7f14BuwFY8MwjJ5iRmYDHrW4 + sp6JS7f14BuwFY8MwjaUSSga93CiM + sp6JS7f14BuwFY8MwjxgLh2FY4Lvt + sp6JS7f14BuwFY8Mwk9hQdNZUgmTB + sp6JS7f14BuwFY8MwkcMXqtFp1sMx + sp6JS7f14BuwFY8MwkzZCDc56jsUB + sp6JS7f14BuwFY8Mwm5Zz7fP24Qym + sp6JS7f14BuwFY8MwmDWqizXSoJRG + sp6JS7f14BuwFY8MwmKHmkNYdMqqi + sp6JS7f14BuwFY8MwmRfAWHxWpGNK + sp6JS7f14BuwFY8MwmjCdXwyhphZ1 + sp6JS7f14BuwFY8MwmmukDAm1w6FL + sp6JS7f14BuwFY8Mwmmz2SzaR9TRH + sp6JS7f14BuwFY8Mwmz2z5mKHXzfn + sp6JS7f14BuwFY8Mwo2xNe5629r5k + sp6JS7f14BuwFY8MwoKy8tZxZrfJw + sp6JS7f14BuwFY8MwoLyQ9aMsq8Dm + sp6JS7f14BuwFY8MwoqqYkewuyZck + sp6JS7f14BuwFY8MwouvvhREVp6Pp + +33 account seeds that produce account IDs with low 32-bits 0xd80df065: + sp6JS7f14BuwFY8Mw5B7ERyhAfgHA + sp6JS7f14BuwFY8Mw5VuW3cF7bm2v + sp6JS7f14BuwFY8Mw5py3t1j7YbFT + sp6JS7f14BuwFY8Mw5qc84SzB6RHr + sp6JS7f14BuwFY8Mw5vGHW1G1hAy8 + sp6JS7f14BuwFY8Mw6gVa8TYukws6 + sp6JS7f14BuwFY8Mw8K9w1RoUAv1w + sp6JS7f14BuwFY8Mw8KvKtB7787CA + sp6JS7f14BuwFY8Mw8Y7WhRbuFzRq + sp6JS7f14BuwFY8Mw8cipw7inRmMn + sp6JS7f14BuwFY8MwFM5fAUNLNB13 + sp6JS7f14BuwFY8MwFSe1zAsht3X3 + sp6JS7f14BuwFY8MwFYNdigqQuHZM + sp6JS7f14BuwFY8MwjWkejj7V4V5Q + sp6JS7f14BuwFY8Mwjd2JGpsjvynq + sp6JS7f14BuwFY8Mwjg1xkducn751 + sp6JS7f14BuwFY8Mwjsp6LnaJvL1W + sp6JS7f14BuwFY8MwjvSbLc9593yH + sp6JS7f14BuwFY8Mwjw2h5wx7U6vZ + sp6JS7f14BuwFY8MwjxKUjtRsmPLH + sp6JS7f14BuwFY8Mwk1Yy8ginDfqv + sp6JS7f14BuwFY8Mwk2HrWhWwZP12 + sp6JS7f14BuwFY8Mwk4SsqiexvpWs + sp6JS7f14BuwFY8Mwk66zCs5ACpE6 + sp6JS7f14BuwFY8MwkCwx6vY97Nwh + sp6JS7f14BuwFY8MwknrbjnhTTWU8 + sp6JS7f14BuwFY8MwkokDy2ShRzQx + sp6JS7f14BuwFY8Mwm3BxnRPNxsuu + sp6JS7f14BuwFY8MwmY9EWdQQsFVr + sp6JS7f14BuwFY8MwmYTWjrDhmk8S + sp6JS7f14BuwFY8Mwo9skXt9Y5BVS + sp6JS7f14BuwFY8MwoZYKZybJ1Crp + sp6JS7f14BuwFY8MwoyXqkhySfSmF + +33 account seeds that produce account IDs with low 32-bits 0xe2e44294: + sp6JS7f14BuwFY8Mw53dmvTgNtBwi + sp6JS7f14BuwFY8Mw5Wrxsqn6WrXW + sp6JS7f14BuwFY8Mw5fGDT31RCXgC + sp6JS7f14BuwFY8Mw5nKRkubwrLWM + sp6JS7f14BuwFY8Mw5nXMajwKjriB + sp6JS7f14BuwFY8Mw5xZybggrC9NG + sp6JS7f14BuwFY8Mw5xea8f6dBMV5 + sp6JS7f14BuwFY8Mw5zDGofAHy5Lb + sp6JS7f14BuwFY8Mw6eado41rQNVG + sp6JS7f14BuwFY8Mw6yqKXQsQJPuU + sp6JS7f14BuwFY8Mw83MSN4FDzSGH + sp6JS7f14BuwFY8Mw8B3pUbzQqHe2 + sp6JS7f14BuwFY8Mw8WwRLnhBRvfk + sp6JS7f14BuwFY8Mw8hDBpKbpJwJX + sp6JS7f14BuwFY8Mw8jggRSZACe7M + sp6JS7f14BuwFY8Mw8mJRpU3qWbwC + sp6JS7f14BuwFY8MwFDnVozykN21u + sp6JS7f14BuwFY8MwFGGRGY9fctgv + sp6JS7f14BuwFY8MwjKznfChH9DQb + sp6JS7f14BuwFY8MwjbC5GvngRCk6 + sp6JS7f14BuwFY8Mwk3Lb7FPe1629 + sp6JS7f14BuwFY8MwkCeS41BwVrBD + sp6JS7f14BuwFY8MwkDnnvRyuWJ7d + sp6JS7f14BuwFY8MwkbkRNnzDEFpf + sp6JS7f14BuwFY8MwkiNhaVhGNk6v + sp6JS7f14BuwFY8Mwm1X4UJXRZx3p + sp6JS7f14BuwFY8Mwm7da9q5vfq7J + sp6JS7f14BuwFY8MwmPLqfBPrHw5H + sp6JS7f14BuwFY8MwmbJpxvVjEwm2 + sp6JS7f14BuwFY8MwoAVeA7ka37cD + sp6JS7f14BuwFY8MwoTFFTAwFKmVM + sp6JS7f14BuwFY8MwoYsne51VpDE3 + sp6JS7f14BuwFY8MwohLVnU1VTk5h + +#endif // 0 diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index 40dfe2fe35c..7dd4a781286 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -482,15 +482,14 @@ class NFToken_test : public beast::unit_test::suite if (replacement->getFieldU32(sfMintedNFTokens) != 1) return false; // Unexpected test conditions. - // Now replace the sfMintedNFTokens with its maximum value. - (*replacement)[sfMintedNFTokens] = - std::numeric_limits::max(); + // Now replace sfMintedNFTokens with the largest valid value. + (*replacement)[sfMintedNFTokens] = 0xFFFF'FFFE; view.rawReplace(replacement); return true; }); - // alice should not be able to mint any tokens because she has already - // minted the maximum allowed by a single account. + // See whether alice is at the boundary that causes an error. + env(token::mint(alice, 0u), ter(tesSUCCESS)); env(token::mint(alice, 0u), ter(tecMAX_SEQUENCE_REACHED)); } @@ -4069,6 +4068,87 @@ class NFToken_test : public beast::unit_test::suite } } + void + testNFTokenOfferOwner(FeatureBitset features) + { + // Verify the Owner field of an offer behaves as expected. + testcase("NFToken offer owner"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const buyer1{"buyer1"}; + Account const buyer2{"buyer2"}; + env.fund(XRP(10000), issuer, buyer1, buyer2); + env.close(); + + // issuer creates an NFT. + uint256 const nftId{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(issuer, 0u), txflags(tfTransferable)); + env.close(); + + // Prove that issuer now owns nftId. + BEAST_EXPECT(nftCount(env, issuer) == 1); + BEAST_EXPECT(nftCount(env, buyer1) == 0); + BEAST_EXPECT(nftCount(env, buyer2) == 0); + + // Both buyer1 and buyer2 create buy offers for nftId. + uint256 const buyer1OfferIndex = + keylet::nftoffer(buyer1, env.seq(buyer1)).key; + env(token::createOffer(buyer1, nftId, XRP(100)), token::owner(issuer)); + uint256 const buyer2OfferIndex = + keylet::nftoffer(buyer2, env.seq(buyer2)).key; + env(token::createOffer(buyer2, nftId, XRP(100)), token::owner(issuer)); + env.close(); + + // Lambda that counts the number of buy offers for a given NFT. + auto nftBuyOfferCount = [&env](uint256 const& nftId) -> std::size_t { + // We know that in this case not very many offers will be + // returned, so we skip the marker stuff. + Json::Value params; + params[jss::nft_id] = to_string(nftId); + Json::Value buyOffers = + env.rpc("json", "nft_buy_offers", to_string(params)); + + if (buyOffers.isMember(jss::result) && + buyOffers[jss::result].isMember(jss::offers)) + return buyOffers[jss::result][jss::offers].size(); + + return 0; + }; + + // Show there are two buy offers for nftId. + BEAST_EXPECT(nftBuyOfferCount(nftId) == 2); + + // issuer accepts buyer1's offer. + env(token::acceptBuyOffer(issuer, buyer1OfferIndex)); + env.close(); + + // Prove that buyer1 now owns nftId. + BEAST_EXPECT(nftCount(env, issuer) == 0); + BEAST_EXPECT(nftCount(env, buyer1) == 1); + BEAST_EXPECT(nftCount(env, buyer2) == 0); + + // buyer1's offer was consumed, but buyer2's offer is still in the + // ledger. + BEAST_EXPECT(nftBuyOfferCount(nftId) == 1); + + // buyer1 can now accept buyer2's offer, even though buyer2's + // NFTokenCreateOffer transaction specified the NFT Owner as issuer. + env(token::acceptBuyOffer(buyer1, buyer2OfferIndex)); + env.close(); + + // Prove that buyer2 now owns nftId. + BEAST_EXPECT(nftCount(env, issuer) == 0); + BEAST_EXPECT(nftCount(env, buyer1) == 0); + BEAST_EXPECT(nftCount(env, buyer2) == 1); + + // All of the NFTokenOffers are now consumed. + BEAST_EXPECT(nftBuyOfferCount(nftId) == 0); + } + void testNFTokenWithTickets(FeatureBitset features) { @@ -4248,6 +4328,235 @@ class NFToken_test : public beast::unit_test::suite env.close(); } + void + testNftXxxOffers(FeatureBitset features) + { + testcase("nft_buy_offers and nft_sell_offers"); + + // The default limit on returned NFToken offers is 250, so we need + // to produce more than 250 offers of each kind in order to exercise + // the marker. + + // Fortunately there's nothing in the rules that says an account + // can't hold more than one offer for the same NFT. So we only + // need two accounts to generate the necessary offers. + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const buyer{"buyer"}; + + // A lot of offers requires a lot for reserve. + env.fund(XRP(1000000), issuer, buyer); + env.close(); + + // Create an NFT that we'll make offers for. + uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(issuer, 0), txflags(tfTransferable)); + env.close(); + + // A lambda that validates nft_XXX_offers query responses. + auto checkOffers = [this, &env, &nftID]( + char const* request, + int expectCount, + int expectMarkerCount, + int line) { + int markerCount = 0; + Json::Value allOffers(Json::arrayValue); + std::string marker; + + // The do/while collects results until no marker is returned. + do + { + Json::Value nftOffers = [&env, &nftID, &request, &marker]() { + Json::Value params; + params[jss::nft_id] = to_string(nftID); + + if (!marker.empty()) + params[jss::marker] = marker; + return env.rpc("json", request, to_string(params)); + }(); + + // If there are no offers for the NFT we get an error + if (expectCount == 0) + { + if (expect( + nftOffers.isMember(jss::result), + "expected \"result\"", + __FILE__, + line)) + { + if (expect( + nftOffers[jss::result].isMember(jss::error), + "expected \"error\"", + __FILE__, + line)) + { + expect( + nftOffers[jss::result][jss::error].asString() == + "objectNotFound", + "expected \"objectNotFound\"", + __FILE__, + line); + } + } + break; + } + + marker.clear(); + if (expect( + nftOffers.isMember(jss::result), + "expected \"result\"", + __FILE__, + line)) + { + Json::Value& result = nftOffers[jss::result]; + + if (result.isMember(jss::marker)) + { + ++markerCount; + marker = result[jss::marker].asString(); + } + + if (expect( + result.isMember(jss::offers), + "expected \"offers\"", + __FILE__, + line)) + { + Json::Value& someOffers = result[jss::offers]; + for (std::size_t i = 0; i < someOffers.size(); ++i) + allOffers.append(someOffers[i]); + } + } + } while (!marker.empty()); + + // Verify the contents of allOffers makes sense. + expect( + allOffers.size() == expectCount, + "Unexpected returned offer count", + __FILE__, + line); + expect( + markerCount == expectMarkerCount, + "Unexpected marker count", + __FILE__, + line); + std::optional globalFlags; + std::set offerIndexes; + std::set amounts; + for (Json::Value const& offer : allOffers) + { + // The flags on all found offers should be the same. + if (!globalFlags) + globalFlags = offer[jss::flags].asInt(); + + expect( + *globalFlags == offer[jss::flags].asInt(), + "Inconsistent flags returned", + __FILE__, + line); + + // The test conditions should produce unique indexes and + // amounts for all offers. + offerIndexes.insert(offer[jss::nft_offer_index].asString()); + amounts.insert(offer[jss::amount].asString()); + } + + expect( + offerIndexes.size() == expectCount, + "Duplicate indexes returned?", + __FILE__, + line); + expect( + amounts.size() == expectCount, + "Duplicate amounts returned?", + __FILE__, + line); + }; + + // There are no sell offers. + checkOffers("nft_sell_offers", 0, false, __LINE__); + + // A lambda that generates sell offers. + STAmount sellPrice = XRP(0); + auto makeSellOffers = + [&env, &issuer, &nftID, &sellPrice](STAmount const& limit) { + // Save a little test time by not closing too often. + int offerCount = 0; + while (sellPrice < limit) + { + sellPrice += XRP(1); + env(token::createOffer(issuer, nftID, sellPrice), + txflags(tfSellNFToken)); + if (++offerCount % 10 == 0) + env.close(); + } + env.close(); + }; + + // There is one sell offer. + makeSellOffers(XRP(1)); + checkOffers("nft_sell_offers", 1, 0, __LINE__); + + // There are 250 sell offers. + makeSellOffers(XRP(250)); + checkOffers("nft_sell_offers", 250, 0, __LINE__); + + // There are 251 sell offers. + makeSellOffers(XRP(251)); + checkOffers("nft_sell_offers", 251, 1, __LINE__); + + // There are 500 sell offers. + makeSellOffers(XRP(500)); + checkOffers("nft_sell_offers", 500, 1, __LINE__); + + // There are 501 sell offers. + makeSellOffers(XRP(501)); + checkOffers("nft_sell_offers", 501, 2, __LINE__); + + // There are no buy offers. + checkOffers("nft_buy_offers", 0, 0, __LINE__); + + // A lambda that generates buy offers. + STAmount buyPrice = XRP(0); + auto makeBuyOffers = + [&env, &buyer, &issuer, &nftID, &buyPrice](STAmount const& limit) { + // Save a little test time by not closing too often. + int offerCount = 0; + while (buyPrice < limit) + { + buyPrice += XRP(1); + env(token::createOffer(buyer, nftID, buyPrice), + token::owner(issuer)); + if (++offerCount % 10 == 0) + env.close(); + } + env.close(); + }; + + // There is one buy offer; + makeBuyOffers(XRP(1)); + checkOffers("nft_buy_offers", 1, 0, __LINE__); + + // There are 250 buy offers. + makeBuyOffers(XRP(250)); + checkOffers("nft_buy_offers", 250, 0, __LINE__); + + // There are 251 buy offers. + makeBuyOffers(XRP(251)); + checkOffers("nft_buy_offers", 251, 1, __LINE__); + + // There are 500 buy offers. + makeBuyOffers(XRP(500)); + checkOffers("nft_buy_offers", 500, 1, __LINE__); + + // There are 501 buy offers. + makeBuyOffers(XRP(501)); + checkOffers("nft_buy_offers", 501, 2, __LINE__); + } + void testWithFeats(FeatureBitset features) { @@ -4271,8 +4580,10 @@ class NFToken_test : public beast::unit_test::suite testCancelOffers(features); testCancelTooManyOffers(features); testBrokeredAccept(features); + testNFTokenOfferOwner(features); testNFTokenWithTickets(features); testNFTokenDeleteAccount(features); + testNftXxxOffers(features); } public: @@ -4280,8 +4591,11 @@ class NFToken_test : public beast::unit_test::suite run() override { using namespace test::jtx; - auto const sa = supported_amendments(); - testWithFeats(sa); + FeatureBitset const all{supported_amendments()}; + FeatureBitset const fixNFTDir{fixNFTokenDirV1}; + + testWithFeats(all - fixNFTDir); + testWithFeats(all); } };