diff --git a/Builds/CMake/RippledCore.cmake b/Builds/CMake/RippledCore.cmake index c01077a5aa2..35b147ad491 100644 --- a/Builds/CMake/RippledCore.cmake +++ b/Builds/CMake/RippledCore.cmake @@ -420,6 +420,11 @@ target_sources (rippled PRIVATE src/ripple/app/tx/impl/DepositPreauth.cpp src/ripple/app/tx/impl/Escrow.cpp src/ripple/app/tx/impl/InvariantCheck.cpp + src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp + src/ripple/app/tx/impl/NFTokenBurn.cpp + src/ripple/app/tx/impl/NFTokenCancelOffer.cpp + src/ripple/app/tx/impl/NFTokenCreateOffer.cpp + src/ripple/app/tx/impl/NFTokenMint.cpp src/ripple/app/tx/impl/OfferStream.cpp src/ripple/app/tx/impl/PayChan.cpp src/ripple/app/tx/impl/Payment.cpp @@ -432,6 +437,7 @@ target_sources (rippled PRIVATE src/ripple/app/tx/impl/Transactor.cpp src/ripple/app/tx/impl/apply.cpp src/ripple/app/tx/impl/applySteps.cpp + src/ripple/app/tx/impl/details/NFTokenUtils.cpp #[===============================[ main sources: subdir: basics (partial) @@ -593,6 +599,7 @@ target_sources (rippled PRIVATE src/ripple/rpc/handlers/LogLevel.cpp src/ripple/rpc/handlers/LogRotate.cpp src/ripple/rpc/handlers/Manifest.cpp + src/ripple/rpc/handlers/NFTOffers.cpp src/ripple/rpc/handlers/NodeToShard.cpp src/ripple/rpc/handlers/NoRippleCheck.cpp src/ripple/rpc/handlers/OwnerInfo.cpp @@ -687,6 +694,9 @@ if (tests) src/test/app/LoadFeeTrack_test.cpp src/test/app/Manifest_test.cpp src/test/app/MultiSign_test.cpp + src/test/app/NFToken_test.cpp + src/test/app/NFTokenBurn_test.cpp + src/test/app/NFTokenDir_test.cpp src/test/app/OfferStream_test.cpp src/test/app/Offer_test.cpp src/test/app/OversizeMeta_test.cpp @@ -836,6 +846,7 @@ if (tests) src/test/jtx/impl/sig.cpp src/test/jtx/impl/tag.cpp src/test/jtx/impl/ticket.cpp + src/test/jtx/impl/token.cpp src/test/jtx/impl/trust.cpp src/test/jtx/impl/txflags.cpp src/test/jtx/impl/utility.cpp diff --git a/src/ripple/app/misc/NetworkOPs.cpp b/src/ripple/app/misc/NetworkOPs.cpp index e5dd5765d9a..4b44cf431c7 100644 --- a/src/ripple/app/misc/NetworkOPs.cpp +++ b/src/ripple/app/misc/NetworkOPs.cpp @@ -1178,6 +1178,7 @@ NetworkOPsImp::processTransaction( if ((newFlags & SF_BAD) != 0) { // cached bad + JLOG(m_journal.warn()) << transaction->getID() << ": cached bad!\n"; transaction->setStatus(INVALID); transaction->setResult(temBAD_SIGNATURE); return; diff --git a/src/ripple/app/tx/impl/CancelOffer.cpp b/src/ripple/app/tx/impl/CancelOffer.cpp index 89d6b62c741..95d51501cb9 100644 --- a/src/ripple/app/tx/impl/CancelOffer.cpp +++ b/src/ripple/app/tx/impl/CancelOffer.cpp @@ -27,8 +27,7 @@ namespace ripple { NotTEC CancelOffer::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto const uTxFlags = ctx.tx.getFlags(); diff --git a/src/ripple/app/tx/impl/CashCheck.cpp b/src/ripple/app/tx/impl/CashCheck.cpp index 9a602d50e5d..b258ae7d9d8 100644 --- a/src/ripple/app/tx/impl/CashCheck.cpp +++ b/src/ripple/app/tx/impl/CashCheck.cpp @@ -125,22 +125,13 @@ CashCheck::preclaim(PreclaimContext const& ctx) return tecDST_TAG_NEEDED; } } + + if (hasExpired(ctx.view, sleCheck->at(~sfExpiration))) { - using duration = NetClock::duration; - using timepoint = NetClock::time_point; - auto const optExpiry = sleCheck->at(~sfExpiration); - - // Expiration is defined in terms of the close time of the parent - // ledger, because we definitively know the time that it closed but - // we do not know the closing time of the ledger that is under - // construction. - if (optExpiry && - (ctx.view.parentCloseTime() >= timepoint{duration{*optExpiry}})) - { - JLOG(ctx.j.warn()) << "Cashing a check that has already expired."; - return tecEXPIRED; - } + JLOG(ctx.j.warn()) << "Cashing a check that has already expired."; + return tecEXPIRED; } + { // Preflight verified exactly one of Amount or DeliverMin is present. // Make sure the requested amount is reasonable. diff --git a/src/ripple/app/tx/impl/Change.cpp b/src/ripple/app/tx/impl/Change.cpp index 8c34f532d15..bd66d7d5863 100644 --- a/src/ripple/app/tx/impl/Change.cpp +++ b/src/ripple/app/tx/impl/Change.cpp @@ -180,7 +180,7 @@ Change::applyAmendment() // This amendment now has a majority newMajorities.push_back(STObject(sfMajority)); auto& entry = newMajorities.back(); - entry.emplace_back(STHash256(sfAmendment, amendment)); + entry.emplace_back(STUInt256(sfAmendment, amendment)); entry.emplace_back(STUInt32( sfCloseTime, view().parentCloseTime().time_since_epoch().count())); diff --git a/src/ripple/app/tx/impl/CreateCheck.cpp b/src/ripple/app/tx/impl/CreateCheck.cpp index da5a70e647f..a59a7c12eba 100644 --- a/src/ripple/app/tx/impl/CreateCheck.cpp +++ b/src/ripple/app/tx/impl/CreateCheck.cpp @@ -146,21 +146,10 @@ CreateCheck::preclaim(PreclaimContext const& ctx) } } } + if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) { - using duration = NetClock::duration; - using timepoint = NetClock::time_point; - auto const optExpiry = ctx.tx[~sfExpiration]; - - // Expiration is defined in terms of the close time of the parent - // ledger, because we definitively know the time that it closed but - // we do not know the closing time of the ledger that is under - // construction. - if (optExpiry && - (ctx.view.parentCloseTime() >= timepoint{duration{*optExpiry}})) - { - JLOG(ctx.j.warn()) << "Creating a check that has already expired."; - return tecEXPIRED; - } + JLOG(ctx.j.warn()) << "Creating a check that has already expired."; + return tecEXPIRED; } return tesSUCCESS; } diff --git a/src/ripple/app/tx/impl/CreateOffer.cpp b/src/ripple/app/tx/impl/CreateOffer.cpp index 833d18d88d7..4ec41f2b358 100644 --- a/src/ripple/app/tx/impl/CreateOffer.cpp +++ b/src/ripple/app/tx/impl/CreateOffer.cpp @@ -42,8 +42,7 @@ CreateOffer::makeTxConsequences(PreflightContext const& ctx) NotTEC CreateOffer::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto& tx = ctx.tx; @@ -173,14 +172,7 @@ CreateOffer::preclaim(PreclaimContext const& ctx) return temBAD_SEQUENCE; } - using d = NetClock::duration; - using tp = NetClock::time_point; - auto const expiration = ctx.tx[~sfExpiration]; - - // Expiration is defined in terms of the close time of the parent ledger, - // because we definitively know the time that it closed but we do not - // know the closing time of the ledger that is under construction. - if (expiration && (ctx.view.parentCloseTime() >= tp{d{*expiration}})) + if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) { // Note that this will get checked again in applyGuts, but it saves // us a call to checkAcceptAsset and possible false negative. @@ -951,13 +943,8 @@ CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) } auto const expiration = ctx_.tx[~sfExpiration]; - using d = NetClock::duration; - using tp = NetClock::time_point; - // Expiration is defined in terms of the close time of the parent ledger, - // because we definitively know the time that it closed but we do not - // know the closing time of the ledger that is under construction. - if (expiration && (sb.parentCloseTime() >= tp{d{*expiration}})) + if (hasExpired(sb, expiration)) { // If the offer has expired, the transaction has successfully // done nothing, so short circuit from here. diff --git a/src/ripple/app/tx/impl/DeleteAccount.cpp b/src/ripple/app/tx/impl/DeleteAccount.cpp index 16d7eb2051e..da2244bca5e 100644 --- a/src/ripple/app/tx/impl/DeleteAccount.cpp +++ b/src/ripple/app/tx/impl/DeleteAccount.cpp @@ -20,12 +20,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -40,9 +42,7 @@ DeleteAccount::preflight(PreflightContext const& ctx) if (ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - auto const ret = preflight1(ctx); - - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; if (ctx.tx[sfAccount] == ctx.tx[sfDestination]) @@ -127,6 +127,21 @@ removeDepositPreauthFromLedger( return DepositPreauth::removeFromLedger(app, view, delIndex, j); } +TER +removeNFTokenOfferFromLedger( + Application& app, + ApplyView& view, + AccountID const& account, + uint256 const& delIndex, + std::shared_ptr const& sleDel, + beast::Journal) +{ + if (!nft::deleteTokenOffer(view, sleDel)) + return tefBAD_LEDGER; + + return tesSUCCESS; +} + // Return nullptr if the LedgerEntryType represents an obligation that can't // be deleted. Otherwise return the pointer to the function that can delete // the non-obligation @@ -143,6 +158,8 @@ nonObligationDeleter(LedgerEntryType t) return removeTicketFromLedger; case ltDEPOSIT_PREAUTH: return removeDepositPreauthFromLedger; + case ltNFTOKEN_OFFER: + return removeNFTokenOfferFromLedger; default: return nullptr; } @@ -177,6 +194,25 @@ DeleteAccount::preclaim(PreclaimContext const& ctx) if (!sleAccount) return terNO_ACCOUNT; + if (ctx.view.rules().enabled(featureNonFungibleTokensV1)) + { + // If an issuer has any issued NFTs resident in the ledger then it + // cannot be deleted. + if ((*sleAccount)[~sfMintedNFTokens] != + (*sleAccount)[~sfBurnedNFTokens]) + return tecHAS_OBLIGATIONS; + + // If the account owns any NFTs it cannot be deleted. + Keylet const first = keylet::nftpage_min(account); + Keylet const last = keylet::nftpage_max(account); + + auto const cp = ctx.view.read(Keylet( + ltNFTOKEN_PAGE, + ctx.view.succ(first.key, last.key.next()).value_or(last.key))); + if (cp) + return tecHAS_OBLIGATIONS; + } + // We don't allow an account to be deleted if its sequence number // is within 256 of the current ledger. This prevents replay of old // transactions if this account is resurrected after it is deleted. @@ -197,10 +233,10 @@ DeleteAccount::preclaim(PreclaimContext const& ctx) unsigned int uDirEntry{0}; uint256 dirEntry{beast::zero}; + // Account has no directory at all. This _should_ have been caught + // by the dirIsEmpty() check earlier, but it's okay to catch it here. if (!cdirFirst( ctx.view, ownerDirKeylet.key, sleDirNode, uDirEntry, dirEntry)) - // Account has no directory at all. This _should_ have been caught - // by the dirIsEmpty() check earlier, but it's okay to catch it here. return tesSUCCESS; std::int32_t deletableDirEntryCount{0}; diff --git a/src/ripple/app/tx/impl/DeleteAccount.h b/src/ripple/app/tx/impl/DeleteAccount.h index c01991ca72c..b0dbaa5bc7e 100644 --- a/src/ripple/app/tx/impl/DeleteAccount.h +++ b/src/ripple/app/tx/impl/DeleteAccount.h @@ -31,14 +31,6 @@ class DeleteAccount : public Transactor public: static constexpr ConsequencesFactoryType ConsequencesFactory{Blocker}; - // Set a reasonable upper limit on the number of deletable directory - // entries an account may have before we decide the account can't be - // deleted. - // - // A limit is useful because if we go much past this limit the - // transaction will fail anyway due to too much metadata (tecOVERSIZE). - static constexpr std::int32_t maxDeletableDirEntries{1000}; - explicit DeleteAccount(ApplyContext& ctx) : Transactor(ctx) { } diff --git a/src/ripple/app/tx/impl/DepositPreauth.cpp b/src/ripple/app/tx/impl/DepositPreauth.cpp index f49490cf8ad..7d99e63017a 100644 --- a/src/ripple/app/tx/impl/DepositPreauth.cpp +++ b/src/ripple/app/tx/impl/DepositPreauth.cpp @@ -33,8 +33,7 @@ DepositPreauth::preflight(PreflightContext const& ctx) if (!ctx.rules.enabled(featureDepositPreauth)) return temDISABLED; - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto& tx = ctx.tx; diff --git a/src/ripple/app/tx/impl/Escrow.cpp b/src/ripple/app/tx/impl/Escrow.cpp index bafa6da04e8..7486dfaca4b 100644 --- a/src/ripple/app/tx/impl/Escrow.cpp +++ b/src/ripple/app/tx/impl/Escrow.cpp @@ -102,8 +102,7 @@ EscrowCreate::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fix1543) && ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; if (!isXRP(ctx.tx[sfAmount])) @@ -298,11 +297,8 @@ EscrowFinish::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fix1543) && ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) - return ret; - } + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; auto const cb = ctx.tx[~sfCondition]; auto const fb = ctx.tx[~sfFulfillment]; @@ -511,8 +507,7 @@ EscrowCancel::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fix1543) && ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; return preflight2(ctx); diff --git a/src/ripple/app/tx/impl/InvariantCheck.cpp b/src/ripple/app/tx/impl/InvariantCheck.cpp index 73b20a0f1dd..82f4cea6b3d 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.cpp +++ b/src/ripple/app/tx/impl/InvariantCheck.cpp @@ -22,7 +22,9 @@ #include #include #include +#include #include +#include namespace ripple { @@ -366,6 +368,8 @@ LedgerEntryTypesMatch::visitEntry( case ltCHECK: case ltDEPOSIT_PREAUTH: case ltNEGATIVE_UNL: + case ltNFTOKEN_PAGE: + case ltNFTOKEN_OFFER: break; default: invalidTypeAdded_ = true; @@ -485,4 +489,189 @@ ValidNewAccountRoot::finalize( return false; } +//------------------------------------------------------------------------------ + +void +ValidNFTokenPage::visitEntry( + bool, + 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; + + if (auto const prev = (*sle)[~sfPreviousPageMin]) + { + if (account != (*prev & accountBits)) + badLink_ = true; + + if (limit <= (*prev & pageBits)) + badLink_ = true; + } + + if (auto const next = (*sle)[~sfNextPageMin]) + { + if (account != (*next & accountBits)) + badLink_ = true; + + if (limit >= (*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; + } + }; + + if (before && before->getType() == ltNFTOKEN_PAGE) + check(before); + + if (after && after->getType() == ltNFTOKEN_PAGE) + check(after); +} + +bool +ValidNFTokenPage::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (badLink_) + { + JLOG(j.fatal()) << "Invariant failed: NFT page is improperly linked."; + return false; + } + + if (badEntry_) + { + JLOG(j.fatal()) << "Invariant failed: NFT found in incorrect page."; + return false; + } + + if (badURI_) + { + JLOG(j.fatal()) << "Invariant failed: NFT contains empty URI."; + return false; + } + + return true; +} + +//------------------------------------------------------------------------------ +void +NFTokenCountTracking::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (before && before->getType() == ltACCOUNT_ROOT) + { + beforeMintedTotal += (*before)[~sfMintedNFTokens].value_or(0); + beforeBurnedTotal += (*before)[~sfBurnedNFTokens].value_or(0); + } + + if (after && after->getType() == ltACCOUNT_ROOT) + { + afterMintedTotal += (*after)[~sfMintedNFTokens].value_or(0); + afterBurnedTotal += (*after)[~sfBurnedNFTokens].value_or(0); + } +} + +bool +NFTokenCountTracking::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (TxType const txType = tx.getTxnType(); + txType != ttNFTOKEN_MINT && txType != ttNFTOKEN_BURN) + { + if (beforeMintedTotal != afterMintedTotal) + { + JLOG(j.fatal()) << "Invariant failed: the number of minted tokens " + "changed without a mint transaction!"; + return false; + } + + if (beforeBurnedTotal != afterBurnedTotal) + { + JLOG(j.fatal()) << "Invariant failed: the number of burned tokens " + "changed without a burn transaction!"; + return false; + } + + return true; + } + + if (tx.getTxnType() == ttNFTOKEN_MINT) + { + if (result == tesSUCCESS && beforeMintedTotal >= afterMintedTotal) + { + JLOG(j.fatal()) + << "Invariant failed: successful minting didn't increase " + "the number of minted tokens."; + return false; + } + + if (result != tesSUCCESS && beforeMintedTotal != afterMintedTotal) + { + JLOG(j.fatal()) << "Invariant failed: failed minting changed the " + "number of minted tokens."; + return false; + } + + if (beforeBurnedTotal != afterBurnedTotal) + { + JLOG(j.fatal()) + << "Invariant failed: minting changed the number of " + "burned tokens."; + return false; + } + } + + if (tx.getTxnType() == ttNFTOKEN_BURN) + { + if (result == tesSUCCESS) + { + if (beforeBurnedTotal >= afterBurnedTotal) + { + JLOG(j.fatal()) + << "Invariant failed: successful burning didn't increase " + "the number of burned tokens."; + return false; + } + } + + if (result != tesSUCCESS && beforeBurnedTotal != afterBurnedTotal) + { + JLOG(j.fatal()) << "Invariant failed: failed burning changed the " + "number of burned tokens."; + return false; + } + + if (beforeMintedTotal != afterMintedTotal) + { + JLOG(j.fatal()) + << "Invariant failed: burning changed the number of " + "minted tokens."; + return false; + } + } + + return true; +} + } // namespace ripple diff --git a/src/ripple/app/tx/impl/InvariantCheck.h b/src/ripple/app/tx/impl/InvariantCheck.h index 4398a31d1b0..5936b59b6a8 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.h +++ b/src/ripple/app/tx/impl/InvariantCheck.h @@ -318,6 +318,51 @@ class ValidNewAccountRoot beast::Journal const&); }; +class ValidNFTokenPage +{ + bool badLink_ = false; + bool badEntry_ = false; + bool badURI_ = false; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +class NFTokenCountTracking +{ + std::uint32_t beforeMintedTotal = 0; + std::uint32_t beforeBurnedTotal = 0; + std::uint32_t afterMintedTotal = 0; + std::uint32_t afterBurnedTotal = 0; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -329,7 +374,9 @@ using InvariantChecks = std::tuple< NoXRPTrustLines, NoBadOffers, NoZeroEscrow, - ValidNewAccountRoot>; + ValidNewAccountRoot, + ValidNFTokenPage, + NFTokenCountTracking>; /** * @brief get a tuple of all invariant checks diff --git a/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp new file mode 100644 index 00000000000..b7997996e40 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp @@ -0,0 +1,369 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include + +namespace ripple { + +NotTEC +NFTokenAcceptOffer::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureNonFungibleTokensV1)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfNFTokenAcceptOfferMask) + return temINVALID_FLAG; + + auto const bo = ctx.tx[~sfNFTokenBuyOffer]; + auto const so = ctx.tx[~sfNFTokenSellOffer]; + + // At least one of these MUST be specified + if (!bo && !so) + return temMALFORMED; + + // The `BrokerFee` field must not be present in direct mode but may be + // present and greater than zero in brokered mode. + if (auto const bf = ctx.tx[~sfNFTokenBrokerFee]) + { + if (!bo || !so) + return temMALFORMED; + + if (*bf <= beast::zero) + return temMALFORMED; + } + + return preflight2(ctx); +} + +TER +NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) +{ + auto const checkOffer = [&ctx](std::optional id) -> TER { + if (id) + { + auto const offer = ctx.view.read(keylet::nftoffer(*id)); + + if (!offer) + return tecOBJECT_NOT_FOUND; + + if (hasExpired(ctx.view, (*offer)[~sfExpiration])) + return tecEXPIRED; + } + + return 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; + + if (buy && sell) + { + // 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; + + // The two offers being brokered must be for the same asset: + if ((*bo)[sfAmount].issue() != (*so)[sfAmount].issue()) + return tecNFTOKEN_BUY_SELL_MISMATCH; + + // Ensure that the buyer is willing to pay at least as much as the + // seller is requesting: + if ((*so)[sfAmount] > (*bo)[sfAmount]) + return tecINSUFFICIENT_PAYMENT; + + // If the seller specified a destination, that destination must be + // the buyer or the broker. + if (auto const dest = so->at(~sfDestination)) + { + if (*dest != bo->at(sfOwner) && *dest != ctx.tx[sfAccount]) + return tecNFTOKEN_BUY_SELL_MISMATCH; + } + + // The broker can specify an amount that represents their cut; if they + // have, ensure that the seller will get at least as much as they want + // to get *after* this fee is accounted for (but before the issuer's + // cut, if any). + if (auto const brokerFee = ctx.tx[~sfNFTokenBrokerFee]) + { + if (brokerFee->issue() != (*bo)[sfAmount].issue()) + return tecNFTOKEN_BUY_SELL_MISMATCH; + + if (brokerFee >= (*bo)[sfAmount]) + return tecINSUFFICIENT_PAYMENT; + + if ((*so)[sfAmount] > (*bo)[sfAmount] - *brokerFee) + return tecINSUFFICIENT_PAYMENT; + } + } + + if (buy) + { + auto const bo = ctx.view.read(keylet::nftoffer(*buy)); + + if (((*bo)[sfFlags] & lsfSellNFToken) == lsfSellNFToken) + return tecNFTOKEN_OFFER_TYPE_MISMATCH; + + // An account can't accept an offer it placed: + if ((*bo)[sfOwner] == ctx.tx[sfAccount]) + return tecCANT_ACCEPT_OWN_NFTOKEN_OFFER; + + // If not in bridged mode, the account must own the token: + if (!sell && + !nft::findToken(ctx.view, ctx.tx[sfAccount], (*bo)[sfNFTokenID])) + return tecNO_PERMISSION; + + // The account offering to buy must have funds: + auto const needed = bo->at(sfAmount); + + if (accountHolds( + ctx.view, + (*bo)[sfOwner], + needed.getCurrency(), + needed.getIssuer(), + fhZERO_IF_FROZEN, + ctx.j) < needed) + return tecINSUFFICIENT_FUNDS; + } + + if (sell) + { + auto const so = ctx.view.read(keylet::nftoffer(*sell)); + + if (((*so)[sfFlags] & lsfSellNFToken) != lsfSellNFToken) + return tecNFTOKEN_OFFER_TYPE_MISMATCH; + + // An account can't accept an offer it placed: + if ((*so)[sfOwner] == ctx.tx[sfAccount]) + return tecCANT_ACCEPT_OWN_NFTOKEN_OFFER; + + // The seller must own the token. + if (!nft::findToken(ctx.view, (*so)[sfOwner], (*so)[sfNFTokenID])) + return tecNO_PERMISSION; + + // If not in bridged mode... + if (!buy) + { + // If the offer has a Destination field, the acceptor must be the + // Destination. + if (auto const dest = so->at(~sfDestination); + dest.has_value() && *dest != ctx.tx[sfAccount]) + return tecNO_PERMISSION; + } + + // The account offering to buy must have funds: + auto const needed = so->at(sfAmount); + + if (accountHolds( + ctx.view, + ctx.tx[sfAccount], + needed.getCurrency(), + needed.getIssuer(), + fhZERO_IF_FROZEN, + ctx.j) < needed) + return tecINSUFFICIENT_FUNDS; + } + + return tesSUCCESS; +} + +TER +NFTokenAcceptOffer::pay( + AccountID const& from, + AccountID const& to, + STAmount const& amount) +{ + // This should never happen, but it's easy and quick to check. + if (amount < beast::zero) + return tecINTERNAL; + + return accountSend(view(), from, to, amount, j_); +} + +TER +NFTokenAcceptOffer::acceptOffer(std::shared_ptr const& offer) +{ + bool const isSell = offer->isFlag(lsfSellNFToken); + AccountID const owner = (*offer)[sfOwner]; + AccountID const& seller = isSell ? owner : account_; + AccountID const& buyer = isSell ? account_ : owner; + + auto const nftokenID = (*offer)[sfNFTokenID]; + + if (auto amount = offer->getFieldAmount(sfAmount); amount != beast::zero) + { + // Calculate the issuer's cut from this sale, if any: + if (auto const fee = nft::getTransferFee(nftokenID); fee != 0) + { + auto const cut = multiply(amount, nft::transferFeeAsRate(fee)); + + if (auto const issuer = nft::getIssuer(nftokenID); + cut != beast::zero && seller != issuer && buyer != issuer) + { + if (auto const r = pay(buyer, issuer, cut); !isTesSuccess(r)) + return r; + amount -= cut; + } + } + + // Send the remaining funds to the seller of the NFT + if (auto const r = pay(buyer, seller, amount); !isTesSuccess(r)) + return r; + } + + // Now transfer the NFT: + auto tokenAndPage = nft::findTokenAndPage(view(), seller, nftokenID); + + if (!tokenAndPage) + return tecINTERNAL; + + if (auto const ret = nft::removeToken( + view(), seller, nftokenID, std::move(tokenAndPage->page)); + !isTesSuccess(ret)) + return ret; + + return nft::insertToken(view(), buyer, std::move(tokenAndPage->token)); +} + +TER +NFTokenAcceptOffer::doApply() +{ + auto const loadToken = [this](std::optional const& id) { + std::shared_ptr sle; + if (id) + sle = view().peek(keylet::nftoffer(*id)); + return sle; + }; + + auto bo = loadToken(ctx_.tx[~sfNFTokenBuyOffer]); + auto so = loadToken(ctx_.tx[~sfNFTokenSellOffer]); + + if (bo && !nft::deleteTokenOffer(view(), bo)) + { + JLOG(j_.fatal()) << "Unable to delete buy offer '" + << to_string(bo->key()) << "': ignoring"; + return tecINTERNAL; + } + + if (so && !nft::deleteTokenOffer(view(), so)) + { + JLOG(j_.fatal()) << "Unable to delete sell offer '" + << to_string(so->key()) << "': ignoring"; + return tecINTERNAL; + } + + // Bridging two different offers + if (bo && so) + { + AccountID const buyer = (*bo)[sfOwner]; + AccountID const seller = (*so)[sfOwner]; + + auto const nftokenID = (*so)[sfNFTokenID]; + + // The amount is what the buyer of the NFT pays: + STAmount amount = (*bo)[sfAmount]; + + // Three different folks may be paid. The order of operations is + // important. + // + // o The broker is paid the cut they requested. + // o The issuer's cut is calculated from what remains after the + // broker is paid. The issuer can take up to 50% of the remainder. + // o Finally, the seller gets whatever is left. + // + // It is important that the issuer's cut be calculated after the + // broker's portion is already removed. Calculating the issuer's + // cut before the broker's cut is removed can result in more money + // being paid out than the seller authorized. That would be bad! + + // Send the broker the amount they requested. + if (auto const cut = ctx_.tx[~sfNFTokenBrokerFee]; + cut && cut.value() != beast::zero) + { + if (auto const r = pay(buyer, account_, cut.value()); + !isTesSuccess(r)) + return r; + + amount -= cut.value(); + } + + // Calculate the issuer's cut, if any. + if (auto const fee = nft::getTransferFee(nftokenID); + amount != beast::zero && fee != 0) + { + auto cut = multiply(amount, nft::transferFeeAsRate(fee)); + + if (auto const issuer = nft::getIssuer(nftokenID); + seller != issuer && buyer != issuer) + { + if (auto const r = pay(buyer, issuer, cut); !isTesSuccess(r)) + return r; + + amount -= cut; + } + } + + // And send whatever remains to the seller. + if (amount > beast::zero) + { + if (auto const r = pay(buyer, seller, amount); !isTesSuccess(r)) + return r; + } + + auto tokenAndPage = nft::findTokenAndPage(view(), seller, nftokenID); + + if (!tokenAndPage) + return tecINTERNAL; + + if (auto const ret = nft::removeToken( + view(), seller, nftokenID, std::move(tokenAndPage->page)); + !isTesSuccess(ret)) + return ret; + + return nft::insertToken(view(), buyer, std::move(tokenAndPage->token)); + } + + if (bo) + return acceptOffer(bo); + + if (so) + return acceptOffer(so); + + return tecINTERNAL; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/NFTokenAcceptOffer.h b/src/ripple/app/tx/impl/NFTokenAcceptOffer.h new file mode 100644 index 00000000000..2d1b14ba284 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenAcceptOffer.h @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_NFTOKENACCEPTOFFER_H_INCLUDED +#define RIPPLE_TX_NFTOKENACCEPTOFFER_H_INCLUDED + +#include + +namespace ripple { + +class NFTokenAcceptOffer : public Transactor +{ +private: + TER + pay(AccountID const& from, AccountID const& to, STAmount const& amount); + + TER + acceptOffer(std::shared_ptr const& offer); + + TER + bridgeOffers( + std::shared_ptr const& buy, + std::shared_ptr const& sell); + +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit NFTokenAcceptOffer(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/NFTokenBurn.cpp b/src/ripple/app/tx/impl/NFTokenBurn.cpp new file mode 100644 index 00000000000..f1f5ae8a787 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenBurn.cpp @@ -0,0 +1,132 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include +#include +#include +#include + +namespace ripple { + +NotTEC +NFTokenBurn::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureNonFungibleTokensV1)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + + return preflight2(ctx); +} + +TER +NFTokenBurn::preclaim(PreclaimContext const& ctx) +{ + auto const owner = [&ctx]() { + if (ctx.tx.isFieldPresent(sfOwner)) + return ctx.tx.getAccountID(sfOwner); + + return ctx.tx[sfAccount]; + }(); + + if (!nft::findToken(ctx.view, owner, ctx.tx[sfNFTokenID])) + return tecNO_ENTRY; + + // The owner of a token can always burn it, but the issuer can only + // do so if the token is marked as burnable. + if (auto const account = ctx.tx[sfAccount]; owner != account) + { + if (!(nft::getFlags(ctx.tx[sfNFTokenID]) & nft::flagBurnable)) + return tecNO_PERMISSION; + + if (auto const issuer = nft::getIssuer(ctx.tx[sfNFTokenID]); + issuer != account) + { + if (auto const sle = ctx.view.read(keylet::account(issuer)); sle) + { + if (auto const minter = (*sle)[~sfNFTokenMinter]; + minter != account) + return tecNO_PERMISSION; + } + } + } + + 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; +} + +TER +NFTokenBurn::doApply() +{ + // Remove the token, effectively burning it: + auto const ret = nft::removeToken( + view(), + ctx_.tx.isFieldPresent(sfOwner) ? ctx_.tx.getAccountID(sfOwner) + : ctx_.tx.getAccountID(sfAccount), + ctx_.tx[sfNFTokenID]); + + // Should never happen since preclaim() verified the token is present. + if (!isTesSuccess(ret)) + return ret; + + if (auto issuer = + view().peek(keylet::account(nft::getIssuer(ctx_.tx[sfNFTokenID])))) + { + (*issuer)[~sfBurnedNFTokens] = + (*issuer)[~sfBurnedNFTokens].value_or(0) + 1; + view().update(issuer); + } + + // Optimized deletion of all offers. + nft::removeAllTokenOffers(view(), keylet::nft_sells(ctx_.tx[sfNFTokenID])); + nft::removeAllTokenOffers(view(), keylet::nft_buys(ctx_.tx[sfNFTokenID])); + + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/NFTokenBurn.h b/src/ripple/app/tx/impl/NFTokenBurn.h new file mode 100644 index 00000000000..61079c4a49a --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenBurn.h @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_BURNNFT_H_INCLUDED +#define RIPPLE_TX_BURNNFT_H_INCLUDED + +#include + +namespace ripple { + +class NFTokenBurn : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit NFTokenBurn(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/NFTokenCancelOffer.cpp b/src/ripple/app/tx/impl/NFTokenCancelOffer.cpp new file mode 100644 index 00000000000..50199ace88b --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenCancelOffer.cpp @@ -0,0 +1,115 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include + +namespace ripple { + +NotTEC +NFTokenCancelOffer::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureNonFungibleTokensV1)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfNFTokenCancelOfferMask) + return temINVALID_FLAG; + + if (auto const& ids = ctx.tx[sfNFTokenOffers]; + ids.empty() || (ids.size() > maxTokenOfferCancelCount)) + return temMALFORMED; + + // In order to prevent unnecessarily overlarge transactions, we + // disallow duplicates in the list of offers to cancel. + STVector256 ids = ctx.tx.getFieldV256(sfNFTokenOffers); + std::sort(ids.begin(), ids.end()); + if (std::adjacent_find(ids.begin(), ids.end()) != ids.end()) + return temMALFORMED; + + return preflight2(ctx); +} + +TER +NFTokenCancelOffer::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + + auto const& ids = ctx.tx[sfNFTokenOffers]; + + auto ret = std::find_if( + ids.begin(), ids.end(), [&ctx, &account](uint256 const& id) { + auto const offer = ctx.view.read(keylet::child(id)); + + // If id is not in the ledger we assume the offer was consumed + // before we got here. + if (!offer) + return false; + + // If id is in the ledger but is not an NFTokenOffer, then + // they have no permission. + if (offer->getType() != ltNFTOKEN_OFFER) + return true; + + // Anyone can cancel, if expired + if (hasExpired(ctx.view, (*offer)[~sfExpiration])) + return false; + + // The owner can always cancel + if ((*offer)[sfOwner] == account) + return false; + + // The recipient can always cancel + if (auto const dest = (*offer)[~sfDestination]; dest == account) + return false; + + return true; + }); + + if (ret != ids.end()) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +TER +NFTokenCancelOffer::doApply() +{ + for (auto const& id : ctx_.tx[sfNFTokenOffers]) + { + if (auto offer = view().peek(keylet::nftoffer(id)); + offer && !nft::deleteTokenOffer(view(), offer)) + { + JLOG(j_.fatal()) << "Unable to delete token offer " << id + << " (ledger " << view().seq() << ")"; + return tefBAD_LEDGER; + } + } + + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/NFTokenCancelOffer.h b/src/ripple/app/tx/impl/NFTokenCancelOffer.h new file mode 100644 index 00000000000..752d33ac818 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenCancelOffer.h @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_NFTOKENCANCELOFFER_H_INCLUDED +#define RIPPLE_TX_NFTOKENCANCELOFFER_H_INCLUDED + +#include + +namespace ripple { + +class NFTokenCancelOffer : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit NFTokenCancelOffer(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp b/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp new file mode 100644 index 00000000000..bf92472e2ce --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp @@ -0,0 +1,234 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include + +namespace ripple { + +NotTEC +NFTokenCreateOffer::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureNonFungibleTokensV1)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + auto const txFlags = ctx.tx.getFlags(); + bool const isSellOffer = txFlags & tfSellNFToken; + + if (txFlags & tfNFTokenCreateOfferMask) + return temINVALID_FLAG; + + auto const account = ctx.tx[sfAccount]; + auto const nftFlags = nft::getFlags(ctx.tx[sfNFTokenID]); + + { + auto const amount = ctx.tx[sfAmount]; + + if (!isXRP(amount)) + { + if (nftFlags & nft::flagOnlyXRP) + return temBAD_AMOUNT; + + if (!amount) + return temBAD_AMOUNT; + } + + // If this is an offer to buy, you must offer something; if it's an + // offer to sell, you can ask for nothing. + if (!isSellOffer && !amount) + return temBAD_AMOUNT; + } + + if (auto exp = ctx.tx[~sfExpiration]; exp == 0) + return temBAD_EXPIRATION; + + auto const owner = ctx.tx[~sfOwner]; + + // The 'Owner' field must be present when offering to buy, but can't + // be present when selling (it's implicit): + if (owner.has_value() == isSellOffer) + return temMALFORMED; + + if (owner && owner == account) + return temMALFORMED; + + if (auto dest = ctx.tx[~sfDestination]) + { + // The destination field is only valid on a sell offer; it makes no + // sense in a buy offer. + if (!isSellOffer) + return temMALFORMED; + + // The destination can't be the account executing the transaction. + if (dest == account) + return temMALFORMED; + } + + return preflight2(ctx); +} + +TER +NFTokenCreateOffer::preclaim(PreclaimContext const& ctx) +{ + if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) + return tecEXPIRED; + + auto const nftokenID = ctx.tx[sfNFTokenID]; + bool const isSellOffer = ctx.tx.isFlag(tfSellNFToken); + + if (!nft::findToken( + ctx.view, ctx.tx[isSellOffer ? sfAccount : sfOwner], nftokenID)) + return tecNO_ENTRY; + + auto const nftFlags = nft::getFlags(nftokenID); + auto const issuer = nft::getIssuer(nftokenID); + auto const amount = ctx.tx[sfAmount]; + + if (!(nftFlags & nft::flagCreateTrustLines) && !amount.native() && + nft::getTransferFee(nftokenID)) + { + if (!ctx.view.exists(keylet::account(issuer))) + return tecNO_ISSUER; + + if (!ctx.view.exists(keylet::line(issuer, amount.issue()))) + return tecNO_LINE; + + if (isFrozen( + ctx.view, issuer, amount.getCurrency(), amount.getIssuer())) + return tecFROZEN; + } + + if (issuer != ctx.tx[sfAccount] && !(nftFlags & nft::flagTransferable)) + { + auto const root = ctx.view.read(keylet::account(issuer)); + assert(root); + + if (auto minter = (*root)[~sfNFTokenMinter]; + minter != ctx.tx[sfAccount]) + return tefNFTOKEN_IS_NOT_TRANSFERABLE; + } + + if (isFrozen( + ctx.view, + ctx.tx[sfAccount], + amount.getCurrency(), + amount.getIssuer())) + return tecFROZEN; + + // If this is an offer to buy the token, the account must have the + // needed funds at hand; but note that funds aren't reserved and the + // offer may later become unfunded. + if (!isSellOffer) + { + auto const funds = accountHolds( + ctx.view, + ctx.tx[sfAccount], + amount.getCurrency(), + amount.getIssuer(), + FreezeHandling::fhZERO_IF_FROZEN, + ctx.j); + + if (funds.signum() <= 0) + return tecUNFUNDED_OFFER; + } + + // If a destination is specified, the destination must already be in + // the ledger. + if (auto const destination = ctx.tx[~sfDestination]; + destination && !ctx.view.exists(keylet::account(*destination))) + return tecNO_DST; + + return tesSUCCESS; +} + +TER +NFTokenCreateOffer::doApply() +{ + if (auto const acct = view().read(keylet::account(ctx_.tx[sfAccount])); + mPriorBalance < view().fees().accountReserve((*acct)[sfOwnerCount] + 1)) + return tecINSUFFICIENT_RESERVE; + + auto const nftokenID = ctx_.tx[sfNFTokenID]; + + auto const offerID = + keylet::nftoffer(account_, ctx_.tx.getSeqProxy().value()); + + // Create the offer: + { + // Token offers are always added to the owner's owner directory: + auto const ownerNode = view().dirInsert( + keylet::ownerDir(account_), offerID, describeOwnerDir(account_)); + + if (!ownerNode) + return tecDIR_FULL; + + bool const isSellOffer = ctx_.tx.isFlag(tfSellNFToken); + + // Token offers are also added to the token's buy or sell offer + // directory + auto const offerNode = view().dirInsert( + isSellOffer ? keylet::nft_sells(nftokenID) + : keylet::nft_buys(nftokenID), + offerID, + [&nftokenID, isSellOffer](std::shared_ptr const& sle) { + (*sle)[sfFlags] = + isSellOffer ? lsfNFTokenSellOffers : lsfNFTokenBuyOffers; + (*sle)[sfNFTokenID] = nftokenID; + }); + + if (!offerNode) + return tecDIR_FULL; + + std::uint32_t sleFlags = 0; + + if (isSellOffer) + sleFlags |= lsfSellNFToken; + + auto offer = std::make_shared(offerID); + (*offer)[sfOwner] = account_; + (*offer)[sfNFTokenID] = nftokenID; + (*offer)[sfAmount] = ctx_.tx[sfAmount]; + (*offer)[sfFlags] = sleFlags; + (*offer)[sfOwnerNode] = *ownerNode; + (*offer)[sfNFTokenOfferNode] = *offerNode; + + if (auto const expiration = ctx_.tx[~sfExpiration]) + (*offer)[sfExpiration] = *expiration; + + if (auto const destination = ctx_.tx[~sfDestination]) + (*offer)[sfDestination] = *destination; + + view().insert(offer); + } + + // Update owner count. + adjustOwnerCount(view(), view().peek(keylet::account(account_)), 1, j_); + + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/NFTokenCreateOffer.h b/src/ripple/app/tx/impl/NFTokenCreateOffer.h new file mode 100644 index 00000000000..676b546f4b9 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenCreateOffer.h @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_NFTOKENOFFERCREATE_H_INCLUDED +#define RIPPLE_TX_NFTOKENOFFERCREATE_H_INCLUDED + +#include + +namespace ripple { + +class NFTokenCreateOffer : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit NFTokenCreateOffer(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/NFTokenMint.cpp b/src/ripple/app/tx/impl/NFTokenMint.cpp new file mode 100644 index 00000000000..b4e391c3ee8 --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenMint.cpp @@ -0,0 +1,211 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include +#include +#include +#include +#include + +namespace ripple { + +NotTEC +NFTokenMint::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureNonFungibleTokensV1)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfNFTokenMintMask) + return temINVALID_FLAG; + + if (auto const f = ctx.tx[~sfTransferFee]) + { + if (f > maxTransferFee) + return temBAD_NFTOKEN_TRANSFER_FEE; + + // If a non-zero TransferFee is set then the tfTransferable flag + // must also be set. + if (f > 0u && !ctx.tx.isFlag(tfTransferable)) + return temMALFORMED; + } + + // An issuer must only be set if the tx is executed by the minter + if (auto iss = ctx.tx[~sfIssuer]; iss == ctx.tx[sfAccount]) + return temMALFORMED; + + if (auto uri = ctx.tx[~sfURI]) + { + if (uri->length() == 0 || uri->length() > maxTokenURILength) + return temMALFORMED; + } + + return preflight2(ctx); +} + +uint256 +NFTokenMint::createNFTokenID( + std::uint16_t flags, + std::uint16_t fee, + AccountID const& issuer, + nft::Taxon taxon, + std::uint32_t tokenSeq) +{ + // An issuer may issue several NFTs with the same taxon; to ensure that NFTs + // are spread across multiple pages we lightly mix the taxon up by using the + // sequence (which is not under the issuer's direct control) as the seed for + // a simple linear congruential generator. cipheredTaxon() does this work. + taxon = nft::cipheredTaxon(tokenSeq, taxon); + + // The values are packed inside a 32-byte buffer, so we need to make sure + // that the endianess is fixed. + flags = boost::endian::native_to_big(flags); + fee = boost::endian::native_to_big(fee); + taxon = nft::toTaxon(boost::endian::native_to_big(nft::toUInt32(taxon))); + tokenSeq = boost::endian::native_to_big(tokenSeq); + + std::array buf{}; + + auto ptr = buf.data(); + + // This code is awkward but the idea is to pack these values into a single + // 256-bit value that uniquely identifies this NFT. + std::memcpy(ptr, &flags, sizeof(flags)); + ptr += sizeof(flags); + + std::memcpy(ptr, &fee, sizeof(fee)); + ptr += sizeof(fee); + + std::memcpy(ptr, issuer.data(), issuer.size()); + ptr += issuer.size(); + + std::memcpy(ptr, &taxon, sizeof(taxon)); + ptr += sizeof(taxon); + + std::memcpy(ptr, &tokenSeq, sizeof(tokenSeq)); + ptr += sizeof(tokenSeq); + assert(std::distance(buf.data(), ptr) == buf.size()); + + return uint256::fromVoid(buf.data()); +} + +TER +NFTokenMint::preclaim(PreclaimContext const& ctx) +{ + // The issuer of the NFT may or may not be the account executing this + // transaction. Check that and verify that this is allowed: + if (auto issuer = ctx.tx[~sfIssuer]) + { + auto const sle = ctx.view.read(keylet::account(*issuer)); + + if (!sle) + return tecNO_ISSUER; + + if (auto const minter = (*sle)[~sfNFTokenMinter]; + minter != ctx.tx[sfAccount]) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +NFTokenMint::doApply() +{ + auto const issuer = ctx_.tx[~sfIssuer].value_or(account_); + + auto const tokenSeq = [this, &issuer]() -> Expected { + auto const root = view().peek(keylet::account(issuer)); + if (root == nullptr) + // Should not happen. Checked in preclaim. + return Unexpected(tecNO_ISSUER); + + // Get the unique sequence number for this token: + std::uint32_t const tokenSeq = (*root)[~sfMintedNFTokens].value_or(0); + { + std::uint32_t const nextTokenSeq = tokenSeq + 1; + if (nextTokenSeq < tokenSeq) + return Unexpected(tecMAX_SEQUENCE_REACHED); + + (*root)[sfMintedNFTokens] = nextTokenSeq; + } + ctx_.view().update(root); + return tokenSeq; + }(); + + if (!tokenSeq.has_value()) + return (tokenSeq.error()); + + std::uint32_t const ownerCountBefore = + view().read(keylet::account(account_))->getFieldU32(sfOwnerCount); + + // Assemble the new NFToken. + SOTemplate const* nfTokenTemplate = + InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken); + + if (nfTokenTemplate == nullptr) + // Should never happen. + return tecINTERNAL; + + STObject newToken( + *nfTokenTemplate, + sfNFToken, + [this, &issuer, &tokenSeq](STObject& object) { + object.setFieldH256( + sfNFTokenID, + createNFTokenID( + static_cast(ctx_.tx.getFlags() & 0x0000FFFF), + ctx_.tx[~sfTransferFee].value_or(0), + issuer, + nft::toTaxon(ctx_.tx[sfNFTokenTaxon]), + tokenSeq.value())); + + if (auto const uri = ctx_.tx[~sfURI]) + object.setFieldVL(sfURI, *uri); + }); + + if (TER const ret = + nft::insertToken(ctx_.view(), account_, std::move(newToken)); + ret != tesSUCCESS) + return ret; + + // Only check the reserve if the owner count actually changed. This + // allows NFTs to be added to the page (and burn fees) without + // requiring the reserve to be met each time. The reserve is + // only managed when a new NFT page is added. + if (auto const ownerCountAfter = + view().read(keylet::account(account_))->getFieldU32(sfOwnerCount); + ownerCountAfter > ownerCountBefore) + { + if (auto const reserve = view().fees().accountReserve(ownerCountAfter); + mPriorBalance < reserve) + return tecINSUFFICIENT_RESERVE; + } + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/NFTokenMint.h b/src/ripple/app/tx/impl/NFTokenMint.h new file mode 100644 index 00000000000..690843c19ce --- /dev/null +++ b/src/ripple/app/tx/impl/NFTokenMint.h @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_NFTTOKENMINT_H_INCLUDED +#define RIPPLE_TX_NFTTOKENMINT_H_INCLUDED + +#include +#include + +namespace ripple { + +class NFTokenMint : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit NFTokenMint(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + // Public to support unit tests. + static uint256 + createNFTokenID( + std::uint16_t flags, + std::uint16_t fee, + AccountID const& issuer, + nft::Taxon taxon, + std::uint32_t tokenSeq); +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/PayChan.cpp b/src/ripple/app/tx/impl/PayChan.cpp index 869b1f472d5..aab3dcc5a6b 100644 --- a/src/ripple/app/tx/impl/PayChan.cpp +++ b/src/ripple/app/tx/impl/PayChan.cpp @@ -174,8 +174,7 @@ PayChanCreate::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fix1543) && ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; if (!isXRP(ctx.tx[sfAmount]) || (ctx.tx[sfAmount] <= beast::zero)) @@ -307,8 +306,7 @@ PayChanFund::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fix1543) && ctx.tx.getFlags() & tfUniversalMask) return temINVALID_FLAG; - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; if (!isXRP(ctx.tx[sfAmount]) || (ctx.tx[sfAmount] <= beast::zero)) @@ -395,8 +393,7 @@ PayChanFund::doApply() NotTEC PayChanClaim::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto const bal = ctx.tx[~sfBalance]; diff --git a/src/ripple/app/tx/impl/Payment.cpp b/src/ripple/app/tx/impl/Payment.cpp index 50045da8d37..ccb0f1935a9 100644 --- a/src/ripple/app/tx/impl/Payment.cpp +++ b/src/ripple/app/tx/impl/Payment.cpp @@ -46,8 +46,7 @@ Payment::makeTxConsequences(PreflightContext const& ctx) NotTEC Payment::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto& tx = ctx.tx; diff --git a/src/ripple/app/tx/impl/SetAccount.cpp b/src/ripple/app/tx/impl/SetAccount.cpp index c965457fd91..85fe290ca55 100644 --- a/src/ripple/app/tx/impl/SetAccount.cpp +++ b/src/ripple/app/tx/impl/SetAccount.cpp @@ -58,8 +58,7 @@ SetAccount::makeTxConsequences(PreflightContext const& ctx) NotTEC SetAccount::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto& tx = ctx.tx; @@ -100,7 +99,7 @@ SetAccount::preflight(PreflightContext const& ctx) // RequireDestTag // bool bSetRequireDest = - (uTxFlags & TxFlag::requireDestTag) || (uSetFlag == asfRequireDest); + (uTxFlags & tfRequireDestTag) || (uSetFlag == asfRequireDest); bool bClearRequireDest = (uTxFlags & tfOptionalDestTag) || (uClearFlag == asfRequireDest); @@ -166,13 +165,25 @@ SetAccount::preflight(PreflightContext const& ctx) } } - auto const domain = tx[~sfDomain]; - if (domain && domain->size() > DOMAIN_BYTES_MAX) + if (auto const domain = tx[~sfDomain]; + domain && domain->size() > maxDomainLength) { JLOG(j.trace()) << "domain too long"; return telBAD_DOMAIN; } + if (ctx.rules.enabled(featureNonFungibleTokensV1)) + { + // Configure authorized minting account: + if (uSetFlag == asfAuthorizedNFTokenMinter && + !tx.isFieldPresent(sfNFTokenMinter)) + return temMALFORMED; + + if (uClearFlag == asfAuthorizedNFTokenMinter && + tx.isFieldPresent(sfNFTokenMinter)) + return temMALFORMED; + } + return preflight2(ctx); } @@ -227,7 +238,7 @@ SetAccount::doApply() // legacy AccountSet flags std::uint32_t const uTxFlags{tx.getFlags()}; bool const bSetRequireDest{ - (uTxFlags & TxFlag::requireDestTag) || (uSetFlag == asfRequireDest)}; + (uTxFlags & tfRequireDestTag) || (uSetFlag == asfRequireDest)}; bool const bClearRequireDest{ (uTxFlags & tfOptionalDestTag) || (uClearFlag == asfRequireDest)}; bool const bSetRequireAuth{ @@ -516,6 +527,17 @@ SetAccount::doApply() } } + // Configure authorized minting account: + if (ctx_.view().rules().enabled(featureNonFungibleTokensV1)) + { + if (uSetFlag == asfAuthorizedNFTokenMinter) + sle->setAccountID(sfNFTokenMinter, ctx_.tx[sfNFTokenMinter]); + + if (uClearFlag == asfAuthorizedNFTokenMinter && + sle->isFieldPresent(sfNFTokenMinter)) + sle->makeFieldAbsent(sfNFTokenMinter); + } + if (uFlagsIn != uFlagsOut) sle->setFieldU32(sfFlags, uFlagsOut); diff --git a/src/ripple/app/tx/impl/SetAccount.h b/src/ripple/app/tx/impl/SetAccount.h index cb37c6ecdce..1c6bb4b7d80 100644 --- a/src/ripple/app/tx/impl/SetAccount.h +++ b/src/ripple/app/tx/impl/SetAccount.h @@ -31,8 +31,6 @@ namespace ripple { class SetAccount : public Transactor { - static std::size_t const DOMAIN_BYTES_MAX = 256; - public: static constexpr ConsequencesFactoryType ConsequencesFactory{Custom}; diff --git a/src/ripple/app/tx/impl/SetRegularKey.cpp b/src/ripple/app/tx/impl/SetRegularKey.cpp index aee973a1cbe..1b5a3eedea0 100644 --- a/src/ripple/app/tx/impl/SetRegularKey.cpp +++ b/src/ripple/app/tx/impl/SetRegularKey.cpp @@ -50,8 +50,7 @@ SetRegularKey::calculateBaseFee(ReadView const& view, STTx const& tx) NotTEC SetRegularKey::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; std::uint32_t const uTxFlags = ctx.tx.getFlags(); diff --git a/src/ripple/app/tx/impl/SetSignerList.cpp b/src/ripple/app/tx/impl/SetSignerList.cpp index 8e321c4c18a..78409ba7145 100644 --- a/src/ripple/app/tx/impl/SetSignerList.cpp +++ b/src/ripple/app/tx/impl/SetSignerList.cpp @@ -78,8 +78,7 @@ SetSignerList::determineOperation( NotTEC SetSignerList::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto const result = determineOperation(ctx.tx, ctx.flags, ctx.j); diff --git a/src/ripple/app/tx/impl/SetTrust.cpp b/src/ripple/app/tx/impl/SetTrust.cpp index 5c60f4ed24d..5f268f2c26b 100644 --- a/src/ripple/app/tx/impl/SetTrust.cpp +++ b/src/ripple/app/tx/impl/SetTrust.cpp @@ -30,8 +30,7 @@ namespace ripple { NotTEC SetTrust::preflight(PreflightContext const& ctx) { - auto const ret = preflight1(ctx); - if (!isTesSuccess(ret)) + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; auto& tx = ctx.tx; diff --git a/src/ripple/app/tx/impl/Transactor.cpp b/src/ripple/app/tx/impl/Transactor.cpp index 619ce031b82..9265d365647 100644 --- a/src/ripple/app/tx/impl/Transactor.cpp +++ b/src/ripple/app/tx/impl/Transactor.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -716,6 +717,25 @@ removeUnfundedOffers( } } +static void +removeExpiredNFTokenOffers( + ApplyView& view, + std::vector const& offers, + beast::Journal viewJ) +{ + std::size_t removed = 0; + + for (auto const& index : offers) + { + if (auto const offer = view.peek(keylet::nftoffer(index))) + { + nft::deleteTokenOffer(view, offer); + if (++removed == expiredOfferRemoveLimit) + return; + } + } +} + /** Reset the context, discarding any changes made and adjust the fee */ std::pair Transactor::reset(XRPAmount fee) @@ -807,10 +827,14 @@ Transactor::operator()() } else if ( (result == tecOVERSIZE) || (result == tecKILLED) || - (isTecClaimHardFail(result, view().flags()))) + (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags()))) { JLOG(j_.trace()) << "reapplying because of " << transToken(result); + // FIXME: This mechanism for doing work while returning a `tec` is + // awkward and very limiting. A more general purpose approach + // should be used, making it possible to do more useful work + // when transactions fail with a `tec` code. std::vector removedOffers; if ((result == tecOVERSIZE) || (result == tecKILLED)) @@ -834,6 +858,25 @@ Transactor::operator()() }); } + std::vector expiredNFTokenOffers; + + if (result == tecEXPIRED) + { + ctx_.visit([&expiredNFTokenOffers]( + uint256 const& index, + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) { + if (isDelete) + { + assert(before && after); + if (before && after && + (before->getType() == ltNFTOKEN_OFFER)) + expiredNFTokenOffers.push_back(index); + } + }); + } + // Reset the context, potentially adjusting the fee. { auto const resetResult = reset(fee); @@ -848,6 +891,10 @@ Transactor::operator()() removeUnfundedOffers( view(), removedOffers, ctx_.app.journal("View")); + if (result == tecEXPIRED) + removeExpiredNFTokenOffers( + view(), expiredNFTokenOffers, ctx_.app.journal("View")); + applied = isTecClaim(result); } diff --git a/src/ripple/app/tx/impl/applySteps.cpp b/src/ripple/app/tx/impl/applySteps.cpp index c70ac96d7d9..581a700cf75 100644 --- a/src/ripple/app/tx/impl/applySteps.cpp +++ b/src/ripple/app/tx/impl/applySteps.cpp @@ -29,6 +29,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -132,6 +137,16 @@ invoke_preflight(PreflightContext const& ctx) case ttFEE: case ttUNL_MODIFY: return invoke_preflight_helper(ctx); + case ttNFTOKEN_MINT: + return invoke_preflight_helper(ctx); + case ttNFTOKEN_BURN: + return invoke_preflight_helper(ctx); + case ttNFTOKEN_CREATE_OFFER: + return invoke_preflight_helper(ctx); + case ttNFTOKEN_CANCEL_OFFER: + return invoke_preflight_helper(ctx); + case ttNFTOKEN_ACCEPT_OFFER: + return invoke_preflight_helper(ctx); default: assert(false); return {temUNKNOWN, TxConsequences{temUNKNOWN}}; @@ -223,6 +238,16 @@ invoke_preclaim(PreclaimContext const& ctx) case ttFEE: case ttUNL_MODIFY: return invoke_preclaim(ctx); + case ttNFTOKEN_MINT: + return invoke_preclaim(ctx); + case ttNFTOKEN_BURN: + return invoke_preclaim(ctx); + case ttNFTOKEN_CREATE_OFFER: + return invoke_preclaim(ctx); + case ttNFTOKEN_CANCEL_OFFER: + return invoke_preclaim(ctx); + case ttNFTOKEN_ACCEPT_OFFER: + return invoke_preclaim(ctx); default: assert(false); return temUNKNOWN; @@ -276,6 +301,16 @@ invoke_calculateBaseFee(ReadView const& view, STTx const& tx) case ttFEE: case ttUNL_MODIFY: return Change::calculateBaseFee(view, tx); + case ttNFTOKEN_MINT: + return NFTokenMint::calculateBaseFee(view, tx); + case ttNFTOKEN_BURN: + return NFTokenBurn::calculateBaseFee(view, tx); + case ttNFTOKEN_CREATE_OFFER: + return NFTokenCreateOffer::calculateBaseFee(view, tx); + case ttNFTOKEN_CANCEL_OFFER: + return NFTokenCancelOffer::calculateBaseFee(view, tx); + case ttNFTOKEN_ACCEPT_OFFER: + return NFTokenAcceptOffer::calculateBaseFee(view, tx); default: assert(false); return FeeUnit64{0}; @@ -408,6 +443,26 @@ invoke_apply(ApplyContext& ctx) Change p(ctx); return p(); } + case ttNFTOKEN_MINT: { + NFTokenMint p(ctx); + return p(); + } + case ttNFTOKEN_BURN: { + NFTokenBurn p(ctx); + return p(); + } + case ttNFTOKEN_CREATE_OFFER: { + NFTokenCreateOffer p(ctx); + return p(); + } + case ttNFTOKEN_CANCEL_OFFER: { + NFTokenCancelOffer p(ctx); + return p(); + } + case ttNFTOKEN_ACCEPT_OFFER: { + NFTokenAcceptOffer p(ctx); + return p(); + } default: assert(false); return {temUNKNOWN, false}; diff --git a/src/ripple/app/tx/impl/details/NFTokenUtils.cpp b/src/ripple/app/tx/impl/details/NFTokenUtils.cpp new file mode 100644 index 00000000000..f99c6cf6b17 --- /dev/null +++ b/src/ripple/app/tx/impl/details/NFTokenUtils.cpp @@ -0,0 +1,544 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include +#include +#include +#include + +namespace ripple { + +namespace nft { + +static std::shared_ptr +locatePage(ReadView const& view, AccountID owner, uint256 const& id) +{ + auto const first = keylet::nftpage(keylet::nftpage_min(owner), id); + auto const last = keylet::nftpage_max(owner); + + // This NFT can only be found in the first page with a key that's strictly + // greater than `first`, so look for that, up until the maximum possible + // page. + return view.read(Keylet( + ltNFTOKEN_PAGE, + view.succ(first.key, last.key.next()).value_or(last.key))); +} + +static std::shared_ptr +locatePage(ApplyView& view, AccountID owner, uint256 const& id) +{ + auto const first = keylet::nftpage(keylet::nftpage_min(owner), id); + auto const last = keylet::nftpage_max(owner); + + // This NFT can only be found in the first page with a key that's strictly + // greater than `first`, so look for that, up until the maximum possible + // page. + return view.peek(Keylet( + ltNFTOKEN_PAGE, + view.succ(first.key, last.key.next()).value_or(last.key))); +} + +static std::shared_ptr +getPageForToken( + ApplyView& view, + AccountID const& owner, + uint256 const& id, + std::function const& createCallback) +{ + auto const base = keylet::nftpage_min(owner); + auto const first = keylet::nftpage(base, id); + auto const last = keylet::nftpage_max(owner); + + // This NFT can only be found in the first page with a key that's strictly + // greater than `first`, so look for that, up until the maximum possible + // page. + auto cp = view.peek(Keylet( + ltNFTOKEN_PAGE, + view.succ(first.key, last.key.next()).value_or(last.key))); + + // A suitable page doesn't exist; we'll have to create one. + if (!cp) + { + STArray arr; + cp = std::make_shared(last); + cp->setFieldArray(sfNFTokens, arr); + view.insert(cp); + createCallback(view, owner); + return cp; + } + + STArray narr = cp->getFieldArray(sfNFTokens); + + // The right page still has space: we're good. + if (narr.size() != dirMaxTokensPerPage) + return cp; + + // We need to split the page in two: the first half of the items in this + // page will go into the new page; the rest will stay with the existing + // page. + // + // Note we can't always split the page exactly in half. All equivalent + // NFTs must be kept on the same page. So when the page contains + // equivalent NFTs, the split may be lopsided in order to keep equivalent + // NFTs on the same page. + STArray carr; + { + // We prefer to keep equivalent NFTs on a page boundary. That gives + // any additional equivalent NFTs maximum room for expansion. + // Round up the boundary until there's a non-equivalent entry. + uint256 const cmp = + narr[(dirMaxTokensPerPage / 2) - 1].getFieldH256(sfNFTokenID) & + nft::pageMask; + + // Note that the calls to find_if_not() and (later) find_if() + // rely on the fact that narr is kept in sorted order. + auto splitIter = std::find_if_not( + narr.begin() + (dirMaxTokensPerPage / 2), + narr.end(), + [&cmp](STObject const& obj) { + return (obj.getFieldH256(sfNFTokenID) & nft::pageMask) == cmp; + }); + + // If we get all the way from the middle to the end with only + // equivalent NFTokens then check the front of the page for a + // place to make the split. + if (splitIter == narr.end()) + splitIter = std::find_if( + narr.begin(), narr.end(), [&cmp](STObject const& obj) { + return (obj.getFieldH256(sfNFTokenID) & nft::pageMask) == + 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()) + return nullptr; + + // Split narr at splitIter. + STArray newCarr( + std::make_move_iterator(splitIter), + std::make_move_iterator(narr.end())); + narr.erase(splitIter, narr.end()); + std::swap(carr, newCarr); + } + + auto np = std::make_shared( + keylet::nftpage(base, carr[0].getFieldH256(sfNFTokenID))); + np->setFieldArray(sfNFTokens, narr); + np->setFieldH256(sfNextPageMin, cp->key()); + + if (auto ppm = (*cp)[~sfPreviousPageMin]) + { + np->setFieldH256(sfPreviousPageMin, *ppm); + + if (auto p3 = view.peek(Keylet(ltNFTOKEN_PAGE, *ppm))) + { + p3->setFieldH256(sfNextPageMin, np->key()); + view.update(p3); + } + } + + view.insert(np); + + cp->setFieldArray(sfNFTokens, carr); + cp->setFieldH256(sfPreviousPageMin, np->key()); + view.update(cp); + + createCallback(view, owner); + + return (first.key <= np->key()) ? np : cp; +} + +static bool +compareTokens(uint256 const& a, uint256 const& b) +{ + // The sort of NFTokens needs to be fully deterministic, but the sort + // is weird because we sort on the low 96-bits first. But if the low + // 96-bits are identical we still need a fully deterministic sort. + // So we sort on the low 96-bits first. If those are equal we sort on + // the whole thing. + if (auto const lowBitsCmp = compare(a & nft::pageMask, b & nft::pageMask); + lowBitsCmp != 0) + return lowBitsCmp < 0; + + return a < b; +} + +/** Insert the token in the owner's token directory. */ +TER +insertToken(ApplyView& view, AccountID owner, STObject&& nft) +{ + assert(nft.isFieldPresent(sfNFTokenID)); + + // First, we need to locate the page the NFT belongs to, creating it + // if necessary. This operation may fail if it is impossible to insert + // the NFT. + std::shared_ptr page = getPageForToken( + view, + owner, + nft[sfNFTokenID], + [](ApplyView& view, AccountID const& owner) { + adjustOwnerCount( + view, + view.peek(keylet::account(owner)), + 1, + beast::Journal{beast::Journal::getNullSink()}); + }); + + if (!page) + return tecNO_SUITABLE_NFTOKEN_PAGE; + + { + auto arr = page->getFieldArray(sfNFTokens); + arr.push_back(std::move(nft)); + + arr.sort([](STObject const& o1, STObject const& o2) { + return compareTokens( + o1.getFieldH256(sfNFTokenID), o2.getFieldH256(sfNFTokenID)); + }); + + page->setFieldArray(sfNFTokens, arr); + } + + view.update(page); + + return tesSUCCESS; +} + +static bool +mergePages( + ApplyView& view, + std::shared_ptr const& p1, + std::shared_ptr const& p2) +{ + if (p1->key() >= p2->key()) + Throw("mergePages: pages passed in out of order!"); + + if ((*p1)[~sfNextPageMin] != p2->key()) + Throw("mergePages: next link broken!"); + + if ((*p2)[~sfPreviousPageMin] != p1->key()) + Throw("mergePages: previous link broken!"); + + auto const p1arr = p1->getFieldArray(sfNFTokens); + auto const p2arr = p2->getFieldArray(sfNFTokens); + + // Now check whether to merge the two pages; it only makes sense to do + // this it would mean that one of them can be deleted as a result of + // the merge. + + if (p1arr.size() + p2arr.size() > dirMaxTokensPerPage) + return false; + + STArray x(p1arr.size() + p2arr.size()); + + std::merge( + p1arr.begin(), + p1arr.end(), + p2arr.begin(), + p2arr.end(), + std::back_inserter(x), + [](STObject const& a, STObject const& b) { + return compareTokens( + a.getFieldH256(sfNFTokenID), b.getFieldH256(sfNFTokenID)); + }); + + p2->setFieldArray(sfNFTokens, x); + + // So, at this point we need to unlink "p1" (since we just emptied it) but + // we need to first relink the directory: if p1 has a previous page (p0), + // load it, point it to p2 and point p2 to it. + + p2->makeFieldAbsent(sfPreviousPageMin); + + if (auto const ppm = (*p1)[~sfPreviousPageMin]) + { + auto p0 = view.peek(Keylet(ltNFTOKEN_PAGE, *ppm)); + + if (!p0) + Throw("mergePages: p0 can't be located!"); + + p0->setFieldH256(sfNextPageMin, p2->key()); + view.update(p0); + + p2->setFieldH256(sfPreviousPageMin, *ppm); + } + + view.update(p2); + view.erase(p1); + + return true; +} + +/** Remove the token from the owner's token directory. */ +TER +removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID) +{ + std::shared_ptr page = locatePage(view, owner, nftokenID); + + // If the page couldn't be found, the given NFT isn't owned by this account + if (!page) + return tecNO_ENTRY; + + return removeToken(view, owner, nftokenID, std::move(page)); +} + +/** Remove the token from the owner's token directory. */ +TER +removeToken( + ApplyView& view, + AccountID const& owner, + uint256 const& nftokenID, + std::shared_ptr&& curr) +{ + // We found a page, but the given NFT may not be in it. + auto arr = curr->getFieldArray(sfNFTokens); + + { + auto x = std::find_if( + arr.begin(), arr.end(), [&nftokenID](STObject const& obj) { + return (obj[sfNFTokenID] == nftokenID); + }); + + if (x == arr.end()) + return tecNO_ENTRY; + + arr.erase(x); + } + + // Page management: + auto const loadPage = [&view]( + std::shared_ptr const& page1, + SF_UINT256 const& field) { + std::shared_ptr page2; + + if (auto const id = (*page1)[~field]) + { + page2 = view.peek(Keylet(ltNFTOKEN_PAGE, *id)); + + if (!page2) + Throw( + "page " + to_string(page1->key()) + " has a broken " + + field.getName() + " field pointing to " + to_string(*id)); + } + + return page2; + }; + + auto const prev = loadPage(curr, sfPreviousPageMin); + auto const next = loadPage(curr, sfNextPageMin); + + if (!arr.empty()) + { + // The current page isn't empty. Update it and then try to consolidate + // pages. Note that this consolidation attempt may actually merge three + // pages into one! + curr->setFieldArray(sfNFTokens, arr); + view.update(curr); + + int cnt = 0; + + if (prev && mergePages(view, prev, curr)) + cnt--; + + if (next && mergePages(view, curr, next)) + cnt--; + + if (cnt != 0) + adjustOwnerCount( + view, + view.peek(keylet::account(owner)), + cnt, + beast::Journal{beast::Journal::getNullSink()}); + + return tesSUCCESS; + } + + // The page is empty, so we can just unlink it and then remove it. + if (prev) + { + // Make our previous page point to our next page: + if (next) + prev->setFieldH256(sfNextPageMin, next->key()); + else + prev->makeFieldAbsent(sfNextPageMin); + + view.update(prev); + } + + if (next) + { + // Make our next page point to our previous page: + if (prev) + next->setFieldH256(sfPreviousPageMin, prev->key()); + else + next->makeFieldAbsent(sfPreviousPageMin); + + view.update(next); + } + + view.erase(curr); + + int cnt = 1; + + // Since we're here, try to consolidate the previous and current pages + // of the page we removed (if any) into one. mergePages() _should_ + // always return false. Since tokens are burned one at a time, there + // should never be a page containing one token sitting between two pages + // that have few enough tokens that they can be merged. + // + // But, in case that analysis is wrong, it's good to leave this code here + // just in case. + if (prev && next && + mergePages( + view, + view.peek(Keylet(ltNFTOKEN_PAGE, prev->key())), + view.peek(Keylet(ltNFTOKEN_PAGE, next->key())))) + cnt++; + + adjustOwnerCount( + view, + view.peek(keylet::account(owner)), + -1 * cnt, + beast::Journal{beast::Journal::getNullSink()}); + + return tesSUCCESS; +} + +std::optional +findToken( + ReadView const& view, + AccountID const& owner, + uint256 const& nftokenID) +{ + std::shared_ptr page = locatePage(view, owner, nftokenID); + + // If the page couldn't be found, the given NFT isn't owned by this account + if (!page) + return std::nullopt; + + // We found a candidate page, but the given NFT may not be in it. + for (auto const& t : page->getFieldArray(sfNFTokens)) + { + if (t[sfNFTokenID] == nftokenID) + return t; + } + + return std::nullopt; +} + +std::optional +findTokenAndPage( + ApplyView& view, + AccountID const& owner, + uint256 const& nftokenID) +{ + std::shared_ptr page = locatePage(view, owner, nftokenID); + + // If the page couldn't be found, the given NFT isn't owned by this account + if (!page) + return std::nullopt; + + // We found a candidate page, but the given NFT may not be in it. + for (auto const& t : page->getFieldArray(sfNFTokens)) + { + if (t[sfNFTokenID] == nftokenID) + // This std::optional constructor is explicit, so it is spelled out. + return std::optional( + std::in_place, t, std::move(page)); + } + return std::nullopt; +} +void +removeAllTokenOffers(ApplyView& view, Keylet const& directory) +{ + view.dirDelete(directory, [&view](uint256 const& id) { + auto offer = view.peek(Keylet{ltNFTOKEN_OFFER, id}); + + if (!offer) + Throw( + "Offer " + to_string(id) + " not found in ledger!"); + + auto const owner = (*offer)[sfOwner]; + + if (!view.dirRemove( + keylet::ownerDir(owner), + (*offer)[sfOwnerNode], + offer->key(), + false)) + Throw( + "Offer " + to_string(id) + " not found in owner directory!"); + + adjustOwnerCount( + view, + view.peek(keylet::account(owner)), + -1, + beast::Journal{beast::Journal::getNullSink()}); + + view.erase(offer); + }); +} + +bool +deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer) +{ + if (offer->getType() != ltNFTOKEN_OFFER) + return false; + + auto const owner = (*offer)[sfOwner]; + + if (!view.dirRemove( + keylet::ownerDir(owner), + (*offer)[sfOwnerNode], + offer->key(), + false)) + return false; + + auto const nftokenID = (*offer)[sfNFTokenID]; + + if (!view.dirRemove( + ((*offer)[sfFlags] & tfSellNFToken) ? keylet::nft_sells(nftokenID) + : keylet::nft_buys(nftokenID), + (*offer)[sfNFTokenOfferNode], + offer->key(), + false)) + return false; + + adjustOwnerCount( + view, + view.peek(keylet::account(owner)), + -1, + beast::Journal{beast::Journal::getNullSink()}); + + view.erase(offer); + return true; +} + +} // namespace nft +} // namespace ripple diff --git a/src/ripple/app/tx/impl/details/NFTokenUtils.h b/src/ripple/app/tx/impl/details/NFTokenUtils.h new file mode 100644 index 00000000000..aac5dbf5fa7 --- /dev/null +++ b/src/ripple/app/tx/impl/details/NFTokenUtils.h @@ -0,0 +1,186 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_IMPL_DETAILS_NFTOKENUTILS_H_INCLUDED +#define RIPPLE_TX_IMPL_DETAILS_NFTOKENUTILS_H_INCLUDED + +#include +#include +#include +#include +#include + +namespace ripple { + +namespace nft { + +// Separate taxons from regular integers. +struct TaxonTag +{ +}; +using Taxon = tagged_integer; + +inline Taxon +toTaxon(std::uint32_t i) +{ + return static_cast(i); +} + +inline std::uint32_t +toUInt32(Taxon t) +{ + return static_cast(t); +} + +constexpr std::uint16_t const flagBurnable = 0x0001; +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); + +void +removeAllTokenOffers(ApplyView& view, Keylet const& directory); + +/** Finds the specified token in the owner's token directory. */ +std::optional +findToken( + ReadView const& view, + AccountID const& owner, + uint256 const& nftokenID); + +/** Finds the token in the owner's token directory. Returns token and page. */ +struct TokenAndPage +{ + STObject token; + std::shared_ptr page; + + TokenAndPage(STObject const& token_, std::shared_ptr page_) + : token(token_), page(std::move(page_)) + { + } +}; +std::optional +findTokenAndPage( + ApplyView& view, + AccountID const& owner, + uint256 const& nftokenID); + +/** Insert the token in the owner's token directory. */ +TER +insertToken(ApplyView& view, AccountID owner, STObject&& nft); + +/** Remove the token from the owner's token directory. */ +TER +removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID); + +TER +removeToken( + ApplyView& view, + AccountID const& owner, + uint256 const& nftokenID, + std::shared_ptr&& page); + +/** Deletes the given token offer. + + An offer is tracked in two separate places: + - The token's 'buy' directory, if it's a buy offer; or + - The token's 'sell' directory, if it's a sell offer; and + - The owner directory of the account that placed the offer. + + The offer also consumes one incremental reserve. + */ +bool +deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer); + +inline std::uint16_t +getFlags(uint256 const& id) +{ + std::uint16_t flags; + memcpy(&flags, id.begin(), 2); + return boost::endian::big_to_native(flags); +} + +inline std::uint16_t +getTransferFee(uint256 const& id) +{ + std::uint16_t fee; + memcpy(&fee, id.begin() + 2, 2); + return boost::endian::big_to_native(fee); +} + +inline std::uint32_t +getSerial(uint256 const& id) +{ + std::uint32_t seq; + memcpy(&seq, id.begin() + 28, 4); + return boost::endian::big_to_native(seq); +} + +inline Taxon +cipheredTaxon(std::uint32_t tokenSeq, Taxon taxon) +{ + // An issuer may issue several NFTs with the same taxon; to ensure that NFTs + // are spread across multiple pages we lightly mix the taxon up by using the + // sequence (which is not under the issuer's direct control) as the seed for + // a simple linear congruential generator. + // + // From the Hull-Dobell theorem we know that f(x)=(m*x+c) mod n will yield a + // permutation of [0, n) when n is a power of 2 if m is congruent to 1 mod 4 + // and c is odd. + // + // Here we use m = 384160001 and c = 2459. The modulo is implicit because we + // use 2^32 for n and the arithmetic gives it to us for "free". + // + // Note that the scramble value we calculate is not cryptographically secure + // but that's fine since all we're looking for is some dispersion. + // + // **IMPORTANT** Changing these numbers would be a breaking change requiring + // an amendment along with a way to distinguish token IDs that + // were generated with the old code. + return taxon ^ toTaxon(((384160001 * tokenSeq) + 2459)); +} + +inline Taxon +getTaxon(uint256 const& id) +{ + std::uint32_t taxon; + memcpy(&taxon, id.begin() + 24, 4); + taxon = boost::endian::big_to_native(taxon); + + // The taxon cipher is just an XOR, so it is reversible by applying the + // XOR a second time. + return cipheredTaxon(getSerial(id), toTaxon(taxon)); +} + +inline AccountID +getIssuer(uint256 const& id) +{ + return AccountID::fromVoid(id.data() + 4); +} + +} // namespace nft + +} // namespace ripple + +#endif // RIPPLE_TX_IMPL_DETAILS_NFTOKENUTILS_H_INCLUDED diff --git a/src/ripple/basics/algorithm.h b/src/ripple/basics/algorithm.h index 673d5e955b3..ed6e8080d9a 100644 --- a/src/ripple/basics/algorithm.h +++ b/src/ripple/basics/algorithm.h @@ -20,6 +20,7 @@ #ifndef RIPPLE_ALGORITHM_H_INCLUDED #define RIPPLE_ALGORITHM_H_INCLUDED +#include #include namespace ripple { diff --git a/src/ripple/basics/base_uint.h b/src/ripple/basics/base_uint.h index 00b38eec7fa..ccbb24a13a6 100644 --- a/src/ripple/basics/base_uint.h +++ b/src/ripple/basics/base_uint.h @@ -332,7 +332,7 @@ class base_uint return *this == beast::zero; } - const base_uint + constexpr base_uint operator~() const { base_uint ret; @@ -437,6 +437,20 @@ class base_uint return ret; } + base_uint + next() const + { + auto ret = *this; + return ++ret; + } + + base_uint + prev() const + { + auto ret = *this; + return --ret; + } + base_uint& operator+=(const base_uint& b) { diff --git a/src/ripple/consensus/LedgerTrie.h b/src/ripple/consensus/LedgerTrie.h index 108da0a30ad..0bb902ef1cb 100644 --- a/src/ripple/consensus/LedgerTrie.h +++ b/src/ripple/consensus/LedgerTrie.h @@ -21,7 +21,6 @@ #define RIPPLE_APP_CONSENSUS_LEDGERS_TRIE_H_INCLUDED #include -#include #include #include #include diff --git a/src/ripple/ledger/ApplyView.h b/src/ripple/ledger/ApplyView.h index 5394acc78ad..a37ba6c46a1 100644 --- a/src/ripple/ledger/ApplyView.h +++ b/src/ripple/ledger/ApplyView.h @@ -355,6 +355,12 @@ class ApplyView : public ReadView } /** @} */ + /** Remove the specified directory, invoking the callback for every node. */ + bool + dirDelete( + Keylet const& directory, + std::function const&); + /** Remove the specified directory, if it is empty. @param directory the identifier of the directory node to be deleted diff --git a/src/ripple/ledger/View.h b/src/ripple/ledger/View.h index 737fdee382b..ee917115515 100644 --- a/src/ripple/ledger/View.h +++ b/src/ripple/ledger/View.h @@ -48,6 +48,30 @@ namespace ripple { // //------------------------------------------------------------------------------ +/** Determines whether the given expiration time has passed. + + In the XRP Ledger, expiration times are defined as the number of whole + seconds after the "Ripple Epoch" which, for historical reasons, is set + to January 1, 2000 (00:00 UTC). + + This is like the way the Unix epoch works, except the Ripple Epoch is + precisely 946,684,800 seconds after the Unix Epoch. + + See https://xrpl.org/basic-data-types.html#specifying-time + + Expiration is defined in terms of the close time of the parent ledger, + because we definitively know the time that it closed (since consensus + agrees on time) but we do not know the closing time of the ledger that + is under construction. + + @param view The ledger whose parent time is used as the clock. + @param exp The optional expiration time we want to check. + + @returns `true` if `exp` is in the past; `false` otherwise. + */ +[[nodiscard]] bool +hasExpired(ReadView const& view, std::optional const& exp); + /** Controls the treatment of frozen account balances */ enum FreezeHandling { fhIGNORE_FREEZE, fhZERO_IF_FROZEN }; @@ -94,12 +118,37 @@ xrpLiquid( std::int32_t ownerCountAdj, beast::Journal j); -/** Iterate all items in an account's owner directory. */ +/** Iterate all items in the given directory. */ void +forEachItem( + ReadView const& view, + Keylet const& root, + std::function const&)> const& f); + +/** Iterate all items after an item in the given directory. + @param after The key of the item to start after + @param hint The directory page containing `after` + @param limit The maximum number of items to return + @return `false` if the iteration failed +*/ +bool +forEachItemAfter( + ReadView const& view, + Keylet const& root, + uint256 const& after, + std::uint64_t const hint, + unsigned int limit, + std::function const&)> const& f); + +/** Iterate all items in an account's owner directory. */ +inline void forEachItem( ReadView const& view, AccountID const& id, - std::function const&)> f); + std::function const&)> const& f) +{ + return forEachItem(view, keylet::ownerDir(id), f); +} /** Iterate all items after an item in an owner directory. @param after The key of the item to start after @@ -107,14 +156,17 @@ forEachItem( @param limit The maximum number of items to return @return `false` if the iteration failed */ -bool +inline bool forEachItemAfter( ReadView const& view, AccountID const& id, uint256 const& after, std::uint64_t const hint, unsigned int limit, - std::function const&)> f); + std::function const&)> const& f) +{ + return forEachItemAfter(view, keylet::ownerDir(id), after, hint, limit, f); +} [[nodiscard]] Rate transferRate(ReadView const& view, AccountID const& issuer); diff --git a/src/ripple/ledger/impl/ApplyView.cpp b/src/ripple/ledger/impl/ApplyView.cpp index 5c550b67b61..eced521fb5d 100644 --- a/src/ripple/ledger/impl/ApplyView.cpp +++ b/src/ripple/ledger/impl/ApplyView.cpp @@ -334,4 +334,29 @@ ApplyView::dirRemove( return true; } +bool +ApplyView::dirDelete( + Keylet const& directory, + std::function const& callback) +{ + std::optional pi; + + do + { + auto const page = peek(keylet::page(directory, pi.value_or(0))); + + if (!page) + return false; + + for (auto const& item : page->getFieldV256(sfIndexes)) + callback(item); + + pi = (*page)[~sfIndexNext]; + + erase(page); + } while (pi); + + return true; +} + } // namespace ripple diff --git a/src/ripple/ledger/impl/View.cpp b/src/ripple/ledger/impl/View.cpp index e7b03343234..54e78ecc991 100644 --- a/src/ripple/ledger/impl/View.cpp +++ b/src/ripple/ledger/impl/View.cpp @@ -18,17 +18,14 @@ //============================================================================== #include -#include #include #include -#include #include #include #include #include #include #include -#include #include #include @@ -101,7 +98,8 @@ internalDirFirst( else page = view.peek(keylet::page(root)); - assert(page); + if (!page) + return false; index = 0; @@ -177,6 +175,15 @@ addRaw(LedgerInfo const& info, Serializer& s, bool includeHash) s.addBitString(info.hash); } +bool +hasExpired(ReadView const& view, std::optional const& exp) +{ + using d = NetClock::duration; + using tp = NetClock::time_point; + + return exp && (view.parentCloseTime() >= tp{d{*exp}}); +} + bool isGlobalFrozen(ReadView const& view, AccountID const& issuer) { @@ -264,31 +271,16 @@ accountFunds( FreezeHandling freezeHandling, beast::Journal j) { - STAmount saFunds; - if (!saDefault.native() && saDefault.getIssuer() == id) - { - saFunds = saDefault; - JLOG(j.trace()) << "accountFunds:" - << " account=" << to_string(id) - << " saDefault=" << saDefault.getFullText() - << " SELF-FUNDED"; - } - else - { - saFunds = accountHolds( - view, - id, - saDefault.getCurrency(), - saDefault.getIssuer(), - freezeHandling, - j); - JLOG(j.trace()) << "accountFunds:" - << " account=" << to_string(id) - << " saDefault=" << saDefault.getFullText() - << " saFunds=" << saFunds.getFullText(); - } - return saFunds; + return saDefault; + + return accountHolds( + view, + id, + saDefault.getCurrency(), + saDefault.getIssuer(), + freezeHandling, + j); } // Prevent ownerCount from wrapping under error conditions. @@ -374,17 +366,21 @@ xrpLiquid( void forEachItem( ReadView const& view, - AccountID const& id, - std::function const&)> f) + Keylet const& root, + std::function const&)> const& f) { - auto const root = keylet::ownerDir(id); + assert(root.type == ltDIR_NODE); + + if (root.type != ltDIR_NODE) + return; + auto pos = root; - for (;;) + + while (true) { auto sle = view.read(pos); if (!sle) return; - // VFALCO NOTE We aren't checking field exists? for (auto const& key : sle->getFieldV256(sfIndexes)) f(view.read(keylet::child(key))); auto const next = sle->getFieldU64(sfIndexNext); @@ -397,21 +393,25 @@ forEachItem( bool forEachItemAfter( ReadView const& view, - AccountID const& id, + Keylet const& root, uint256 const& after, std::uint64_t const hint, unsigned int limit, - std::function const&)> f) + std::function const&)> const& f) { - auto const rootIndex = keylet::ownerDir(id); - auto currentIndex = rootIndex; + assert(root.type == ltDIR_NODE); + + if (root.type != ltDIR_NODE) + return false; + + auto currentIndex = root; // If startAfter is not zero try jumping to that page using the hint if (after.isNonZero()) { - auto const hintIndex = keylet::page(rootIndex, hint); - auto hintDir = view.read(hintIndex); - if (hintDir) + auto const hintIndex = keylet::page(root, hint); + + if (auto hintDir = view.read(hintIndex)) { for (auto const& key : hintDir->getFieldV256(sfIndexes)) { @@ -446,7 +446,7 @@ forEachItemAfter( auto const uNodeNext = ownerDir->getFieldU64(sfIndexNext); if (uNodeNext == 0) return found; - currentIndex = keylet::page(rootIndex, uNodeNext); + currentIndex = keylet::page(root, uNodeNext); } } else @@ -462,7 +462,7 @@ forEachItemAfter( auto const uNodeNext = ownerDir->getFieldU64(sfIndexNext); if (uNodeNext == 0) return true; - currentIndex = keylet::page(rootIndex, uNodeNext); + currentIndex = keylet::page(root, uNodeNext); } } } diff --git a/src/ripple/net/impl/RPCCall.cpp b/src/ripple/net/impl/RPCCall.cpp index a8d72eda249..820f25ddfc2 100644 --- a/src/ripple/net/impl/RPCCall.cpp +++ b/src/ripple/net/impl/RPCCall.cpp @@ -1238,6 +1238,7 @@ class RPCParser {"account_info", &RPCParser::parseAccountItems, 1, 3}, {"account_lines", &RPCParser::parseAccountLines, 1, 5}, {"account_channels", &RPCParser::parseAccountChannels, 1, 3}, + {"account_nfts", &RPCParser::parseAccountItems, 1, 5}, {"account_objects", &RPCParser::parseAccountItems, 1, 5}, {"account_offers", &RPCParser::parseAccountItems, 1, 4}, {"account_tx", &RPCParser::parseAccountTransactions, 1, 8}, diff --git a/src/ripple/proto/org/xrpl/rpc/v1/common.proto b/src/ripple/proto/org/xrpl/rpc/v1/common.proto index 5eb4cc8c81a..81718b507cf 100644 --- a/src/ripple/proto/org/xrpl/rpc/v1/common.proto +++ b/src/ripple/proto/org/xrpl/rpc/v1/common.proto @@ -15,6 +15,11 @@ import "org/xrpl/rpc/v1/account.proto"; // *** Messages wrapping uint32 *** +message BurnedNFTokens +{ + uint32 value = 1; +} + message CancelAfter { // time in seconds since Ripple epoch @@ -90,6 +95,11 @@ message LowQualityOut uint32 value = 1; } +message MintedNFTokens +{ + uint32 value = 1; +} + message OfferSequence { uint32 value = 1; @@ -189,6 +199,17 @@ message TicketSequence uint32 value = 1; } +message NFTokenTaxon +{ + uint32 value = 1; +} + +message TransferFee +{ + // is actually uint16 + uint32 value = 1; +} + message TransferRate { uint32 value = 1; @@ -233,6 +254,11 @@ message LowNode uint64 value = 1 [jstype=JS_STRING]; } +message NFTokenOfferNode +{ + uint64 value = 1 [jstype=JS_STRING]; +} + message OwnerNode { uint64 value = 1 [jstype=JS_STRING]; @@ -246,6 +272,11 @@ message EmailHash bytes value = 1; } +message NFTokenID +{ + bytes value = 1; +} + // *** Messages wrapping 20 bytes *** @@ -306,6 +337,30 @@ message InvoiceID bytes value = 1; } +message NextPageMin +{ + // 32 bytes + bytes value = 1; +} + +message NFTokenBuyOffer +{ + // 32 bytes + bytes value = 1; +} + +message NFTokenSellOffer +{ + // 32 bytes + bytes value = 1; +} + +message PreviousPageMin +{ + // 32 bytes + bytes value = 1; +} + message PreviousTransactionID { // 32 bytes @@ -413,6 +468,11 @@ message Balance CurrencyAmount value = 1; } +message NFTokenBrokerFee +{ + CurrencyAmount value = 1; +} + message DeliverMin { CurrencyAmount value = 1; @@ -471,6 +531,16 @@ message Destination AccountAddress value = 1; } +message Issuer +{ + AccountAddress value = 1; +} + +message NFTokenMinter +{ + AccountAddress value = 1; +} + message Owner { AccountAddress value = 1; @@ -494,9 +564,22 @@ message Domain string value = 1; } +message URI +{ + string value = 1; +} + // *** Aggregate type messages +// Next field: 3 +message NFToken +{ + NFTokenID nftoken_id = 1; + + URI uri = 2; +} + // Next field: 3 message SignerEntry { diff --git a/src/ripple/proto/org/xrpl/rpc/v1/ledger_objects.proto b/src/ripple/proto/org/xrpl/rpc/v1/ledger_objects.proto index a2666345630..d6db469a213 100644 --- a/src/ripple/proto/org/xrpl/rpc/v1/ledger_objects.proto +++ b/src/ripple/proto/org/xrpl/rpc/v1/ledger_objects.proto @@ -6,7 +6,7 @@ option java_multiple_files = true; import "org/xrpl/rpc/v1/common.proto"; -// Next field: 15 +// Next field: 17 message LedgerObject { oneof object @@ -19,6 +19,8 @@ message LedgerObject Escrow escrow = 6; FeeSettings fee_settings = 7; LedgerHashes ledger_hashes = 8; + NFTokenOffer nftoken_offer = 15; + NFTokenPage nftoken_page = 16; Offer offer = 9; PayChannel pay_channel = 10; RippleState ripple_state = 11; @@ -46,15 +48,19 @@ enum LedgerEntryType LEDGER_ENTRY_TYPE_SIGNER_LIST = 12; LEDGER_ENTRY_TYPE_NEGATIVE_UNL = 13; LEDGER_ENTRY_TYPE_TICKET = 14; + LEDGER_ENTRY_TYPE_NFTOKEN_OFFER = 15; + LEDGER_ENTRY_TYPE_NFTOKEN_PAGE = 16; } -// Next field: 16 +// Next field: 19 message AccountRoot { Account account = 1; Balance balance = 2; + BurnedNFTokens burned_nftokens = 16; + Sequence sequence = 3; Flags flags = 4; @@ -73,13 +79,17 @@ message AccountRoot MessageKey message_key = 11; + MintedNFTokens minted_nftokens = 17; + + NFTokenMinter nftoken_minter = 18; + RegularKey regular_key = 12; TickSize tick_size = 13; - TransferRate transfer_rate = 14; - TicketCount ticket_count = 15; + + TransferRate transfer_rate = 14; } // Next field: 4 @@ -153,7 +163,7 @@ message DepositPreauthObject PreviousTransactionLedgerSequence previous_transaction_ledger_sequence = 6; } -// Next field: 11 +// Next field: 12 message DirectoryNode { Flags flags = 1; @@ -175,6 +185,8 @@ message DirectoryNode TakerGetsCurreny taker_gets_currency = 9; TakerGetsIssuer taker_gets_issuer = 10; + + NFTokenID nftoken_id = 11; } // Next field: 14 @@ -257,6 +269,46 @@ message Offer PreviousTransactionLedgerSequence previous_transaction_ledger_sequence = 11; } +// Next field: 11 +message NFTokenOffer +{ + Flags flags = 1; + + Owner owner = 2; + + NFTokenID nftoken_id = 3; + + Amount amount = 4; + + OwnerNode owner_node = 5; + + NFTokenOfferNode nftoken_offer_node = 6; + + Destination destination = 7; + + Expiration expiration = 8; + + PreviousTransactionID previous_transaction_id = 9; + + PreviousTransactionLedgerSequence previous_transaction_ledger_sequence = 10; +} + +// Next field: 7 +message NFTokenPage +{ + Flags flags = 1; + + PreviousPageMin previous_page_min = 2; + + NextPageMin next_page_min = 3; + + repeated NFToken nftokens = 4; + + PreviousTransactionID previous_transaction_id = 5; + + PreviousTransactionLedgerSequence previous_transaction_ledger_sequence = 6; +} + // Next field: 13 message PayChannel { diff --git a/src/ripple/proto/org/xrpl/rpc/v1/transaction.proto b/src/ripple/proto/org/xrpl/rpc/v1/transaction.proto index 081f22e9ef4..05300422b1a 100644 --- a/src/ripple/proto/org/xrpl/rpc/v1/transaction.proto +++ b/src/ripple/proto/org/xrpl/rpc/v1/transaction.proto @@ -9,7 +9,7 @@ import "org/xrpl/rpc/v1/amount.proto"; import "org/xrpl/rpc/v1/account.proto"; // A message encompassing all transaction types -// Next field: 32 +// Next field: 37 message Transaction { Account account = 1; @@ -41,6 +41,16 @@ message Transaction EscrowFinish escrow_finish = 21; + NFTokenAcceptOffer nftoken_accept_offer = 32; + + NFTokenBurn nftoken_burn = 33; + + NFTokenCancelOffer nftoken_cancel_offer = 34; + + NFTokenCreateOffer nftoken_create_offer = 35; + + NFTokenMint nftoken_mint = 36; + OfferCancel offer_cancel = 22; OfferCreate offer_create = 23; @@ -99,7 +109,7 @@ message Signer SigningPublicKey signing_public_key = 3; } -// Next field: 8 +// Next field: 9 message AccountSet { ClearFlag clear_flag = 1; @@ -115,6 +125,8 @@ message AccountSet TransferRate transfer_rate = 6; TickSize tick_size = 7; + + NFTokenMinter nftoken_minter = 8; } // Next field: 3 @@ -205,6 +217,56 @@ message EscrowFinish Fulfillment fulfillment = 4; } +// Next field: 4 +message NFTokenAcceptOffer +{ + NFTokenBrokerFee nftoken_broker_fee = 1; + + NFTokenBuyOffer nftoken_buy_offer = 2; + + NFTokenSellOffer nftoken_sell_offer = 3; +} + +// Next field: 3 +message NFTokenBurn +{ + Owner owner = 1; + + NFTokenID nftoken_id = 2; +} + +// Next field: 2 +message NFTokenCancelOffer +{ + repeated Index nftoken_offers = 1; +} + +// Next field: 6 +message NFTokenCreateOffer +{ + Amount amount = 1; + + Destination destination = 2; + + Expiration expiration = 3; + + Owner owner = 4; + + NFTokenID nftoken_id = 5; +} + +// Next field: 5 +message NFTokenMint +{ + Issuer issuer = 1; + + NFTokenTaxon nftoken_taxon = 2; + + TransferFee transfer_fee = 3; + + URI uri = 4; +} + // Next field: 2 message OfferCancel { diff --git a/src/ripple/protocol/ErrorCodes.h b/src/ripple/protocol/ErrorCodes.h index 45fa7da2911..98a8cf43a39 100644 --- a/src/ripple/protocol/ErrorCodes.h +++ b/src/ripple/protocol/ErrorCodes.h @@ -139,8 +139,11 @@ enum error_code_i { // Reporting rpcFAILED_TO_FORWARD = 90, rpcREPORTING_UNSUPPORTED = 91, + + rpcOBJECT_NOT_FOUND = 92, + rpcLAST = - rpcREPORTING_UNSUPPORTED // rpcLAST should always equal the last code.= + rpcOBJECT_NOT_FOUND // rpcLAST should always equal the last code.= }; /** Codes returned in the `warnings` array of certain RPC commands. diff --git a/src/ripple/protocol/Feature.h b/src/ripple/protocol/Feature.h index d65e8f8f074..9087bec992e 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 = 46; +static constexpr std::size_t numFeatures = 47; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -333,6 +333,7 @@ extern uint256 const featureFlowSortStrands; extern uint256 const fixSTAmountCanonicalize; extern uint256 const fixRmSmallIncreasedQOffers; extern uint256 const featureCheckCashMakesTrustLine; +extern uint256 const featureNonFungibleTokensV1; } // namespace ripple diff --git a/src/ripple/protocol/Indexes.h b/src/ripple/protocol/Indexes.h index 411f4db241e..f7f35355ee4 100644 --- a/src/ripple/protocol/Indexes.h +++ b/src/ripple/protocol/Indexes.h @@ -106,7 +106,7 @@ static book_t const book{}; to as the issuer and the holder); if Alice sets up a trust line to Bob for BTC, and Bob trusts Alice for BTC, here is only a single BTC trust line between them. - * */ +*/ /** @{ */ Keylet line( @@ -225,6 +225,44 @@ escrow(AccountID const& src, std::uint32_t seq) noexcept; Keylet payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; +/** NFT page keylets + + Unlike objects whose ledger identifiers are produced by hashing data, + NFT page identifiers are composite identifiers, consisting of the owner's + 160-bit AccountID, followed by a 96-bit value that determines which NFT + tokens are candidates for that page. + */ +/** @{ */ +/** A keylet for the owner's first possible NFT page. */ +Keylet +nftpage_min(AccountID const& owner); + +/** A keylet for the owner's last possible NFT page. */ +Keylet +nftpage_max(AccountID const& owner); + +Keylet +nftpage(Keylet const& k, uint256 const& token); +/** @} */ + +/** An offer from an account to buy or sell an NFT */ +Keylet +nftoffer(AccountID const& owner, std::uint32_t seq); + +inline Keylet +nftoffer(uint256 const& offer) +{ + return {ltNFTOKEN_OFFER, offer}; +} + +/** The directory of buy offers for the specified NFT */ +Keylet +nft_buys(uint256 const& id) noexcept; + +/** The directory of sell offers for the specified NFT */ +Keylet +nft_sells(uint256 const& id) noexcept; + } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/src/ripple/protocol/LedgerFormats.h b/src/ripple/protocol/LedgerFormats.h index 1f6079838f2..2dd04b1264b 100644 --- a/src/ripple/protocol/LedgerFormats.h +++ b/src/ripple/protocol/LedgerFormats.h @@ -149,6 +149,18 @@ enum LedgerEntryType : std::uint16_t */ ltNEGATIVE_UNL = 0x004e, + /** A ledger object which contains a list of NFTs + + \sa keylet::nftpage_min, keylet::nftpage_max, keylet::nftpage + */ + ltNFTOKEN_PAGE = 0x0050, + + /** A ledger object which identifies an offer to buy or sell an NFT. + + \sa keylet::nftoffer + */ + ltNFTOKEN_OFFER = 0x0037, + //--------------------------------------------------------------------------- /** A special type, matching any ledger entry type. @@ -237,6 +249,13 @@ enum LedgerSpecificFlags { // ltSIGNER_LIST lsfOneOwnerCount = 0x00010000, // True, uses only one OwnerCount + + // ltDIR_NODE + lsfNFTokenBuyOffers = 0x00000001, + lsfNFTokenSellOffers = 0x00000002, + + // ltNFTOKEN_OFFER + lsfSellNFToken = 0x00000001, }; //------------------------------------------------------------------------------ diff --git a/src/ripple/protocol/Protocol.h b/src/ripple/protocol/Protocol.h index 12609bef131..5df24271f68 100644 --- a/src/ripple/protocol/Protocol.h +++ b/src/ripple/protocol/Protocol.h @@ -26,14 +26,15 @@ namespace ripple { -/** Protocol specific constants, types, and data. +/** Protocol specific constants. - This information is, implicitly, part of the Ripple - protocol. + This information is, implicitly, part of the protocol. @note Changing these values without adding code to the server to detect "pre-change" and "post-change" will result in a hard fork. + + @ingroup protocol */ /** Smallest legal byte size of a transaction. */ std::size_t constexpr txMinSizeBytes = 32; @@ -44,6 +45,9 @@ std::size_t constexpr txMaxSizeBytes = megabytes(1); /** The maximum number of unfunded offers to delete at once */ std::size_t constexpr unfundedOfferRemoveLimit = 1000; +/** The maximum number of expired offers to delete at once */ +std::size_t constexpr expiredOfferRemoveLimit = 256; + /** The maximum number of metadata entries allowed in one transaction */ std::size_t constexpr oversizeMetaDataCap = 5200; @@ -53,6 +57,35 @@ std::size_t constexpr dirNodeMaxEntries = 32; /** The maximum number of pages allowed in a directory */ std::uint64_t constexpr dirNodeMaxPages = 262144; +/** The maximum number of items in an NFT page */ +std::size_t constexpr dirMaxTokensPerPage = 32; + +/** The maximum number of owner directory entries for account to be deletable */ +std::size_t constexpr maxDeletableDirEntries = 1000; + +/** The maximum number of token offers that can be canceled at once */ +std::size_t constexpr maxTokenOfferCancelCount = 500; + +/** The maximum number of offers in an offer directory for NFT to be burnable */ +std::size_t constexpr maxDeletableTokenOfferEntries = 500; + +/** The maximum token transfer fee allowed. + + Token transfer fees can range from 0% to 50% and are specified in tenths of + a basis point; that is a value of 1000 represents a transfer fee of 1% and + a value of 10000 represents a transfer fee of 10%. + + Note that for extremely low transfer fees values, it is possible that the + calculated fee will be 0. + */ +std::uint16_t constexpr maxTransferFee = 50000; + +/** The maximum length of a URI inside an NFT */ +std::size_t constexpr maxTokenURILength = 256; + +/** The maximum length of a domain */ +std::size_t constexpr maxDomainLength = 256; + /** A ledger index. */ using LedgerIndex = std::uint32_t; @@ -62,8 +95,6 @@ using LedgerIndex = std::uint32_t; */ using TxID = uint256; -using TxSeq = std::uint32_t; - } // namespace ripple #endif diff --git a/src/ripple/protocol/Rate.h b/src/ripple/protocol/Rate.h index a982596e515..3524eabb627 100644 --- a/src/ripple/protocol/Rate.h +++ b/src/ripple/protocol/Rate.h @@ -90,6 +90,13 @@ divideRound( Issue const& issue, bool roundUp); +namespace nft { +/** Given a transfer fee (in basis points) convert it to a transfer rate. */ +Rate +transferFeeAsRate(std::uint16_t fee); + +} // namespace nft + /** A transfer rate signifying a 1:1 exchange */ extern Rate const parityRate; diff --git a/src/ripple/protocol/SField.h b/src/ripple/protocol/SField.h index 28da73436b8..5039e4e0524 100644 --- a/src/ripple/protocol/SField.h +++ b/src/ripple/protocol/SField.h @@ -52,15 +52,14 @@ class STVector256; enum SerializedTypeID { // special types STI_UNKNOWN = -2, - STI_DONE = -1, STI_NOTPRESENT = 0, // // types (common) STI_UINT16 = 1, STI_UINT32 = 2, STI_UINT64 = 3, - STI_HASH128 = 4, - STI_HASH256 = 5, + STI_UINT128 = 4, + STI_UINT256 = 5, STI_AMOUNT = 6, STI_VL = 7, STI_ACCOUNT = 8, @@ -70,9 +69,13 @@ enum SerializedTypeID { // types (uncommon) STI_UINT8 = 16, - STI_HASH160 = 17, + STI_UINT160 = 17, STI_PATHSET = 18, STI_VECTOR256 = 19, + STI_UINT96 = 20, + STI_UINT192 = 21, + STI_UINT384 = 22, + STI_UINT512 = 23, // high level types // cannot be serialized inside other types @@ -186,26 +189,18 @@ class SField return jsonName; } - bool - isGeneric() const - { - return fieldCode == 0; - } bool isInvalid() const { return fieldCode == -1; } + bool isUseful() const { return fieldCode > 0; } - bool - isKnown() const - { - return fieldType != STI_UNKNOWN; - } + bool isBinary() const { @@ -238,11 +233,6 @@ class SField return num; } - bool - isSigningField() const - { - return signingField == IsSigning::yes; - } bool shouldMeta(int c) const { @@ -318,9 +308,14 @@ using SF_UINT8 = TypedField>; using SF_UINT16 = TypedField>; using SF_UINT32 = TypedField>; using SF_UINT64 = TypedField>; -using SF_HASH128 = TypedField>; -using SF_HASH160 = TypedField>; -using SF_HASH256 = TypedField>; +using SF_UINT96 = TypedField>; +using SF_UINT128 = TypedField>; +using SF_UINT160 = TypedField>; +using SF_UINT192 = TypedField>; +using SF_UINT256 = TypedField>; +using SF_UINT384 = TypedField>; +using SF_UINT512 = TypedField>; + using SF_ACCOUNT = TypedField; using SF_AMOUNT = TypedField; using SF_VL = TypedField; @@ -335,18 +330,21 @@ extern SField const sfTransaction; extern SField const sfValidation; extern SField const sfMetadata; -// 8-bit integers +// 8-bit integers (common) extern SF_UINT8 const sfCloseResolution; extern SF_UINT8 const sfMethod; extern SF_UINT8 const sfTransactionResult; + +// 8-bit integers (uncommon) extern SF_UINT8 const sfTickSize; extern SF_UINT8 const sfUNLModifyDisabling; extern SF_UINT8 const sfHookResult; -// 16-bit integers +// 16-bit integers (common) extern SF_UINT16 const sfLedgerEntryType; extern SF_UINT16 const sfTransactionType; extern SF_UINT16 const sfSignerWeight; +extern SF_UINT16 const sfTransferFee; // 16-bit integers (uncommon) extern SF_UINT16 const sfVersion; @@ -397,10 +395,13 @@ extern SF_UINT32 const sfSignerListID; extern SF_UINT32 const sfSettleDelay; extern SF_UINT32 const sfTicketCount; extern SF_UINT32 const sfTicketSequence; +extern SF_UINT32 const sfNFTokenTaxon; +extern SF_UINT32 const sfMintedNFTokens; +extern SF_UINT32 const sfBurnedNFTokens; extern SF_UINT32 const sfHookStateCount; extern SF_UINT32 const sfEmitGeneration; -// 64-bit integers +// 64-bit integers (common) extern SF_UINT64 const sfIndexNext; extern SF_UINT64 const sfIndexPrevious; extern SF_UINT64 const sfBookNode; @@ -412,49 +413,57 @@ extern SF_UINT64 const sfHighNode; extern SF_UINT64 const sfDestinationNode; extern SF_UINT64 const sfCookie; extern SF_UINT64 const sfServerVersion; +extern SF_UINT64 const sfNFTokenOfferNode; +extern SF_UINT64 const sfEmitBurden; + +// 64-bit integers (uncommon) extern SF_UINT64 const sfHookOn; extern SF_UINT64 const sfHookInstructionCount; -extern SF_UINT64 const sfEmitBurden; extern SF_UINT64 const sfHookReturnCode; extern SF_UINT64 const sfReferenceCount; // 128-bit -extern SF_HASH128 const sfEmailHash; +extern SF_UINT128 const sfEmailHash; // 160-bit (common) -extern SF_HASH160 const sfTakerPaysCurrency; -extern SF_HASH160 const sfTakerPaysIssuer; -extern SF_HASH160 const sfTakerGetsCurrency; -extern SF_HASH160 const sfTakerGetsIssuer; +extern SF_UINT160 const sfTakerPaysCurrency; +extern SF_UINT160 const sfTakerPaysIssuer; +extern SF_UINT160 const sfTakerGetsCurrency; +extern SF_UINT160 const sfTakerGetsIssuer; // 256-bit (common) -extern SF_HASH256 const sfLedgerHash; -extern SF_HASH256 const sfParentHash; -extern SF_HASH256 const sfTransactionHash; -extern SF_HASH256 const sfAccountHash; -extern SF_HASH256 const sfPreviousTxnID; -extern SF_HASH256 const sfLedgerIndex; -extern SF_HASH256 const sfWalletLocator; -extern SF_HASH256 const sfRootIndex; -extern SF_HASH256 const sfAccountTxnID; -extern SF_HASH256 const sfEmitParentTxnID; -extern SF_HASH256 const sfEmitNonce; -extern SF_HASH256 const sfEmitHookHash; +extern SF_UINT256 const sfLedgerHash; +extern SF_UINT256 const sfParentHash; +extern SF_UINT256 const sfTransactionHash; +extern SF_UINT256 const sfAccountHash; +extern SF_UINT256 const sfPreviousTxnID; +extern SF_UINT256 const sfLedgerIndex; +extern SF_UINT256 const sfWalletLocator; +extern SF_UINT256 const sfRootIndex; +extern SF_UINT256 const sfAccountTxnID; +extern SF_UINT256 const sfNFTokenID; +extern SF_UINT256 const sfEmitParentTxnID; +extern SF_UINT256 const sfEmitNonce; +extern SF_UINT256 const sfEmitHookHash; // 256-bit (uncommon) -extern SF_HASH256 const sfBookDirectory; -extern SF_HASH256 const sfInvoiceID; -extern SF_HASH256 const sfNickname; -extern SF_HASH256 const sfAmendment; -extern SF_HASH256 const sfDigest; -extern SF_HASH256 const sfChannel; -extern SF_HASH256 const sfConsensusHash; -extern SF_HASH256 const sfCheckID; -extern SF_HASH256 const sfValidatedHash; -extern SF_HASH256 const sfHookStateKey; -extern SF_HASH256 const sfHookHash; -extern SF_HASH256 const sfHookNamespace; -extern SF_HASH256 const sfHookSetTxnID; +extern SF_UINT256 const sfBookDirectory; +extern SF_UINT256 const sfInvoiceID; +extern SF_UINT256 const sfNickname; +extern SF_UINT256 const sfAmendment; +extern SF_UINT256 const sfDigest; +extern SF_UINT256 const sfChannel; +extern SF_UINT256 const sfConsensusHash; +extern SF_UINT256 const sfCheckID; +extern SF_UINT256 const sfValidatedHash; +extern SF_UINT256 const sfPreviousPageMin; +extern SF_UINT256 const sfNextPageMin; +extern SF_UINT256 const sfNFTokenBuyOffer; +extern SF_UINT256 const sfNFTokenSellOffer; +extern SF_UINT256 const sfHookStateKey; +extern SF_UINT256 const sfHookHash; +extern SF_UINT256 const sfHookNamespace; +extern SF_UINT256 const sfHookSetTxnID; // currency amount (common) extern SF_AMOUNT const sfAmount; @@ -472,12 +481,14 @@ extern SF_AMOUNT const sfDeliverMin; extern SF_AMOUNT const sfMinimumOffer; extern SF_AMOUNT const sfRippleEscrow; extern SF_AMOUNT const sfDeliveredAmount; +extern SF_AMOUNT const sfNFTokenBrokerFee; // variable length (common) extern SF_VL const sfPublicKey; extern SF_VL const sfMessageKey; extern SF_VL const sfSigningPubKey; extern SF_VL const sfTxnSignature; +extern SF_VL const sfURI; extern SF_VL const sfSignature; extern SF_VL const sfDomain; extern SF_VL const sfFundCode; @@ -507,8 +518,8 @@ extern SF_ACCOUNT const sfDestination; extern SF_ACCOUNT const sfIssuer; extern SF_ACCOUNT const sfAuthorize; extern SF_ACCOUNT const sfUnauthorize; -extern SF_ACCOUNT const sfTarget; extern SF_ACCOUNT const sfRegularKey; +extern SF_ACCOUNT const sfNFTokenMinter; extern SF_ACCOUNT const sfEmitCallback; // account (uncommon) @@ -521,6 +532,7 @@ extern SField const sfPaths; extern SF_VECTOR256 const sfIndexes; extern SF_VECTOR256 const sfHashes; extern SF_VECTOR256 const sfAmendments; +extern SF_VECTOR256 const sfNFTokenOffers; // inner object // OBJECT/1 is reserved for end of object @@ -534,16 +546,20 @@ extern SField const sfNewFields; extern SField const sfTemplateEntry; extern SField const sfMemo; extern SField const sfSignerEntry; +extern SField const sfNFToken; +extern SField const sfEmitDetails; +extern SField const sfHook; + extern SField const sfSigner; extern SField const sfMajority; extern SField const sfDisabledValidator; extern SField const sfEmittedTxn; -extern SField const sfHook; +extern SField const sfHookExecution; extern SField const sfHookDefinition; extern SField const sfHookParameter; extern SField const sfHookGrant; -// array of objects +// array of objects (common) // ARRAY/1 is reserved for end of array // extern SField const sfSigningAccounts; // Never been used. extern SField const sfSigners; @@ -553,13 +569,14 @@ extern SField const sfNecessary; extern SField const sfSufficient; extern SField const sfAffectedNodes; extern SField const sfMemos; +extern SField const sfNFTokens; +extern SField const sfHooks; + +// array of objects (uncommon) extern SField const sfMajorities; extern SField const sfDisabledValidators; -extern SField const sfEmitDetails; extern SField const sfHookExecutions; -extern SField const sfHookExecution; extern SField const sfHookParameters; -extern SField const sfHooks; extern SField const sfHookGrants; //------------------------------------------------------------------------------ diff --git a/src/ripple/protocol/STArray.h b/src/ripple/protocol/STArray.h index 8a38928cbc4..9501307c2c9 100644 --- a/src/ripple/protocol/STArray.h +++ b/src/ripple/protocol/STArray.h @@ -33,12 +33,28 @@ class STArray final : public STBase, public CountedObject list_type v_; public: + using value_type = STObject; using size_type = list_type::size_type; using iterator = list_type::iterator; using const_iterator = list_type::const_iterator; STArray() = default; STArray(STArray const&) = default; + + template < + class Iter, + class = std::enable_if_t::reference, + STObject>>> + explicit STArray(Iter first, Iter last); + + template < + class Iter, + class = std::enable_if_t::reference, + STObject>>> + STArray(SField const& f, Iter first, Iter last); + STArray& operator=(STArray const&) = default; STArray(STArray&&); @@ -120,6 +136,18 @@ class STArray final : public STBase, public CountedObject bool operator!=(const STArray& s) const; + iterator + erase(iterator pos); + + iterator + erase(const_iterator pos); + + iterator + erase(iterator first, iterator last); + + iterator + erase(const_iterator first, const_iterator last); + SerializedTypeID getSType() const override; @@ -138,6 +166,17 @@ class STArray final : public STBase, public CountedObject friend class detail::STVar; }; +template +STArray::STArray(Iter first, Iter last) : v_(first, last) +{ +} + +template +STArray::STArray(SField const& f, Iter first, Iter last) + : STBase(f), v_(first, last) +{ +} + inline STObject& STArray::operator[](std::size_t j) { @@ -247,6 +286,30 @@ STArray::operator!=(const STArray& s) const return v_ != s.v_; } +inline STArray::iterator +STArray::erase(iterator pos) +{ + return v_.erase(pos); +} + +inline STArray::iterator +STArray::erase(const_iterator pos) +{ + return v_.erase(pos); +} + +inline STArray::iterator +STArray::erase(iterator first, iterator last) +{ + return v_.erase(first, last); +} + +inline STArray::iterator +STArray::erase(const_iterator first, const_iterator last) +{ + return v_.erase(first, last); +} + } // namespace ripple #endif diff --git a/src/ripple/protocol/STBitString.h b/src/ripple/protocol/STBitString.h index 2e242a4b21d..1819d54d1cf 100644 --- a/src/ripple/protocol/STBitString.h +++ b/src/ripple/protocol/STBitString.h @@ -75,9 +75,9 @@ class STBitString final : public STBase friend class detail::STVar; }; -using STHash128 = STBitString<128>; -using STHash160 = STBitString<160>; -using STHash256 = STBitString<256>; +using STUInt128 = STBitString<128>; +using STUInt160 = STBitString<160>; +using STUInt256 = STBitString<256>; template inline STBitString::STBitString(SField const& n) : STBase(n) @@ -117,23 +117,23 @@ STBitString::move(std::size_t n, void* buf) template <> inline SerializedTypeID -STHash128::getSType() const +STUInt128::getSType() const { - return STI_HASH128; + return STI_UINT128; } template <> inline SerializedTypeID -STHash160::getSType() const +STUInt160::getSType() const { - return STI_HASH160; + return STI_UINT160; } template <> inline SerializedTypeID -STHash256::getSType() const +STUInt256::getSType() const { - return STI_HASH256; + return STI_UINT256; } template diff --git a/src/ripple/protocol/STObject.h b/src/ripple/protocol/STObject.h index 97bc2b4e5e8..66c16418579 100644 --- a/src/ripple/protocol/STObject.h +++ b/src/ripple/protocol/STObject.h @@ -80,6 +80,14 @@ class STObject : public STBase, public CountedObject virtual ~STObject() = default; STObject(STObject const&) = default; + + template + STObject(SOTemplate const& type, SField const& name, F&& f) + : STObject(type, name) + { + f(*this); + } + STObject& operator=(STObject const&) = default; STObject(STObject&&); @@ -661,9 +669,15 @@ STObject::Proxy::value() const -> value_type auto const t = find(); if (t) return t->value(); + if (style_ == soeINVALID) + { + Throw("Value requested from invalid STObject."); + } if (style_ != soeDEFAULT) + { Throw( "Missing field '" + this->f_->getName() + "'"); + } return value_type{}; } @@ -962,22 +976,23 @@ STObject::at(TypedField const& f) const if (!b) // This is a free object (no constraints) // with no template - Throw("Missing field '" + f.getName() + "'"); - auto const u = dynamic_cast(b); - if (!u) - { - assert(mType); - assert(b->getSType() == STI_NOTPRESENT); - if (mType->style(f) == soeOPTIONAL) - Throw("Missing field '" + f.getName() + "'"); - assert(mType->style(f) == soeDEFAULT); - // Handle the case where value_type is a - // const reference, otherwise we return - // the address of a temporary. - static std::decay_t const dv{}; - return dv; - } - return u->value(); + Throw("Missing field: " + f.getName()); + + if (auto const u = dynamic_cast(b)) + return u->value(); + + assert(mType); + assert(b->getSType() == STI_NOTPRESENT); + + if (mType->style(f) == soeOPTIONAL) + Throw("Missing optional field: " + f.getName()); + + assert(mType->style(f) == soeDEFAULT); + + // Used to help handle the case where value_type is a const reference, + // otherwise we would return the address of a temporary. + static std::decay_t const dv{}; + return dv; } template diff --git a/src/ripple/protocol/TER.h b/src/ripple/protocol/TER.h index 3a135105816..38342f0c139 100644 --- a/src/ripple/protocol/TER.h +++ b/src/ripple/protocol/TER.h @@ -119,6 +119,7 @@ enum TEMcodes : TERUnderlyingType { temUNKNOWN, // An internal intermediate result; should never be returned. temSEQ_AND_TICKET, + temBAD_NFTOKEN_TRANSFER_FEE, }; //------------------------------------------------------------------------------ @@ -161,6 +162,7 @@ enum TEFcodes : TERUnderlyingType { tefINVARIANT_FAILED, tefTOO_BIG, tefNO_TICKET, + tefNFTOKEN_IS_NOT_TRANSFERABLE, }; //------------------------------------------------------------------------------ @@ -223,7 +225,7 @@ enum TECcodes : TERUnderlyingType { // Note: Exact numbers must stay stable. These codes are stored by // value in metadata for historic transactions. - // 100 .. 159 C + // 100 .. 255 C // Claim fee only (ripple transaction with no good paths, pay to // non-existent account, no path) // @@ -278,7 +280,15 @@ enum TECcodes : TERUnderlyingType { tecKILLED = 150, tecHAS_OBLIGATIONS = 151, tecTOO_SOON = 152, - tecHOOK_ERROR [[maybe_unused]] = 153 + tecHOOK_ERROR [[maybe_unused]] = 153, + tecMAX_SEQUENCE_REACHED = 154, + tecNO_SUITABLE_NFTOKEN_PAGE = 155, + tecNFTOKEN_BUY_SELL_MISMATCH = 156, + tecNFTOKEN_OFFER_TYPE_MISMATCH = 157, + tecCANT_ACCEPT_OWN_NFTOKEN_OFFER = 158, + tecINSUFFICIENT_FUNDS = 159, + tecOBJECT_NOT_FOUND = 160, + tecINSUFFICIENT_PAYMENT = 161, }; //------------------------------------------------------------------------------ diff --git a/src/ripple/protocol/TxFlags.h b/src/ripple/protocol/TxFlags.h index 9b75b692a00..0b907c72235 100644 --- a/src/ripple/protocol/TxFlags.h +++ b/src/ripple/protocol/TxFlags.h @@ -24,87 +24,117 @@ namespace ripple { -// -// Transaction flags. -// - /** Transaction flags. - These flags modify the behavior of an operation. + These flags are specified in a transaction's 'Flags' field and modify the + behavior of that transaction. + + There are two types of flags: + + (1) Universal flags: these are flags which apply to, and are interpreted + the same way by, all transactions, except, perhaps, + to special pseudo-transactions. + + (2) Tx-Specific flags: these are flags which are interpreted according + to the type of the transaction being executed. + That is, the same numerical flag value may have + different effects, depending on the transaction + being executed. + + @note The universal transaction flags occupy the high-order 8 bits. The + tx-specific flags occupy the remaining 24 bits. + + @warning Transaction flags form part of the protocol. **Changing them + should be avoided because without special handling, this will + result in a hard fork.** - @note Changing these will create a hard fork @ingroup protocol */ -class TxFlag -{ -public: - explicit TxFlag() = default; - - static std::uint32_t const requireDestTag = 0x00010000; -}; -// VFALCO TODO Move all flags into this container after some study. +// clang-format off // Universal Transaction flags: -const std::uint32_t tfFullyCanonicalSig = 0x80000000; -const std::uint32_t tfUniversal = tfFullyCanonicalSig; -const std::uint32_t tfUniversalMask = ~tfUniversal; +constexpr std::uint32_t tfFullyCanonicalSig = 0x80000000; +constexpr std::uint32_t tfUniversal = tfFullyCanonicalSig; +constexpr std::uint32_t tfUniversalMask = ~tfUniversal; // AccountSet flags: -// VFALCO TODO Javadoc comment every one of these constants -// const std::uint32_t TxFlag::requireDestTag = 0x00010000; -const std::uint32_t tfOptionalDestTag = 0x00020000; -const std::uint32_t tfRequireAuth = 0x00040000; -const std::uint32_t tfOptionalAuth = 0x00080000; -const std::uint32_t tfDisallowXRP = 0x00100000; -const std::uint32_t tfAllowXRP = 0x00200000; -const std::uint32_t tfAccountSetMask = - ~(tfUniversal | TxFlag::requireDestTag | tfOptionalDestTag | tfRequireAuth | +constexpr std::uint32_t tfRequireDestTag = 0x00010000; +constexpr std::uint32_t tfOptionalDestTag = 0x00020000; +constexpr std::uint32_t tfRequireAuth = 0x00040000; +constexpr std::uint32_t tfOptionalAuth = 0x00080000; +constexpr std::uint32_t tfDisallowXRP = 0x00100000; +constexpr std::uint32_t tfAllowXRP = 0x00200000; +constexpr std::uint32_t tfAccountSetMask = + ~(tfUniversal | tfRequireDestTag | tfOptionalDestTag | tfRequireAuth | tfOptionalAuth | tfDisallowXRP | tfAllowXRP); // AccountSet SetFlag/ClearFlag values -const std::uint32_t asfRequireDest = 1; -const std::uint32_t asfRequireAuth = 2; -const std::uint32_t asfDisallowXRP = 3; -const std::uint32_t asfDisableMaster = 4; -const std::uint32_t asfAccountTxnID = 5; -const std::uint32_t asfNoFreeze = 6; -const std::uint32_t asfGlobalFreeze = 7; -const std::uint32_t asfDefaultRipple = 8; -const std::uint32_t asfDepositAuth = 9; +constexpr std::uint32_t asfRequireDest = 1; +constexpr std::uint32_t asfRequireAuth = 2; +constexpr std::uint32_t asfDisallowXRP = 3; +constexpr std::uint32_t asfDisableMaster = 4; +constexpr std::uint32_t asfAccountTxnID = 5; +constexpr std::uint32_t asfNoFreeze = 6; +constexpr std::uint32_t asfGlobalFreeze = 7; +constexpr std::uint32_t asfDefaultRipple = 8; +constexpr std::uint32_t asfDepositAuth = 9; +constexpr std::uint32_t asfAuthorizedNFTokenMinter = 10; // OfferCreate flags: -const std::uint32_t tfPassive = 0x00010000; -const std::uint32_t tfImmediateOrCancel = 0x00020000; -const std::uint32_t tfFillOrKill = 0x00040000; -const std::uint32_t tfSell = 0x00080000; -const std::uint32_t tfOfferCreateMask = +constexpr std::uint32_t tfPassive = 0x00010000; +constexpr std::uint32_t tfImmediateOrCancel = 0x00020000; +constexpr std::uint32_t tfFillOrKill = 0x00040000; +constexpr std::uint32_t tfSell = 0x00080000; +constexpr std::uint32_t tfOfferCreateMask = ~(tfUniversal | tfPassive | tfImmediateOrCancel | tfFillOrKill | tfSell); // Payment flags: -const std::uint32_t tfNoRippleDirect = 0x00010000; -const std::uint32_t tfPartialPayment = 0x00020000; -const std::uint32_t tfLimitQuality = 0x00040000; -const std::uint32_t tfPaymentMask = +constexpr std::uint32_t tfNoRippleDirect = 0x00010000; +constexpr std::uint32_t tfPartialPayment = 0x00020000; +constexpr std::uint32_t tfLimitQuality = 0x00040000; +constexpr std::uint32_t tfPaymentMask = ~(tfUniversal | tfPartialPayment | tfLimitQuality | tfNoRippleDirect); // TrustSet flags: -const std::uint32_t tfSetfAuth = 0x00010000; -const std::uint32_t tfSetNoRipple = 0x00020000; -const std::uint32_t tfClearNoRipple = 0x00040000; -const std::uint32_t tfSetFreeze = 0x00100000; -const std::uint32_t tfClearFreeze = 0x00200000; -const std::uint32_t tfTrustSetMask = +constexpr std::uint32_t tfSetfAuth = 0x00010000; +constexpr std::uint32_t tfSetNoRipple = 0x00020000; +constexpr std::uint32_t tfClearNoRipple = 0x00040000; +constexpr std::uint32_t tfSetFreeze = 0x00100000; +constexpr std::uint32_t tfClearFreeze = 0x00200000; +constexpr std::uint32_t tfTrustSetMask = ~(tfUniversal | tfSetfAuth | tfSetNoRipple | tfClearNoRipple | tfSetFreeze | tfClearFreeze); // EnableAmendment flags: -const std::uint32_t tfGotMajority = 0x00010000; -const std::uint32_t tfLostMajority = 0x00020000; +constexpr std::uint32_t tfGotMajority = 0x00010000; +constexpr std::uint32_t tfLostMajority = 0x00020000; // PaymentChannelClaim flags: -const std::uint32_t tfRenew = 0x00010000; -const std::uint32_t tfClose = 0x00020000; -const std::uint32_t tfPayChanClaimMask = ~(tfUniversal | tfRenew | tfClose); +constexpr std::uint32_t tfRenew = 0x00010000; +constexpr std::uint32_t tfClose = 0x00020000; +constexpr std::uint32_t tfPayChanClaimMask = ~(tfUniversal | tfRenew | tfClose); + +// NFTokenMint flags: +constexpr std::uint32_t const tfBurnable = 0x00000001; +constexpr std::uint32_t const tfOnlyXRP = 0x00000002; +constexpr std::uint32_t const tfTrustLine = 0x00000004; +constexpr std::uint32_t const tfTransferable = 0x00000008; + +constexpr std::uint32_t const tfNFTokenMintMask = + ~(tfUniversal | tfBurnable | tfOnlyXRP | tfTrustLine | tfTransferable); + +// NFTokenCreateOffer flags: +constexpr std::uint32_t const tfSellNFToken = 0x00000001; +constexpr std::uint32_t const tfNFTokenCreateOfferMask = + ~(tfUniversal | tfSellNFToken); + +// NFTokenCancelOffer flags: +constexpr std::uint32_t const tfNFTokenCancelOfferMask = ~(tfUniversal); + +// NFTokenAcceptOffer flags: +constexpr std::uint32_t const tfNFTokenAcceptOfferMask = ~tfUniversal; + +// clang-format on } // namespace ripple diff --git a/src/ripple/protocol/TxFormats.h b/src/ripple/protocol/TxFormats.h index 44f17fde25f..250c29d69c1 100644 --- a/src/ripple/protocol/TxFormats.h +++ b/src/ripple/protocol/TxFormats.h @@ -124,6 +124,21 @@ enum TxType : std::uint16_t /** This transaction type installs a hook. */ ttHOOK_SET [[maybe_unused]] = 22, + /** This transaction mints a new NFT. */ + ttNFTOKEN_MINT = 25, + + /** This transaction burns (i.e. destroys) an existing NFT. */ + ttNFTOKEN_BURN = 26, + + /** This transaction creates a new offer to buy or sell an NFT. */ + ttNFTOKEN_CREATE_OFFER = 27, + + /** This transaction cancels an existing offer to buy or sell an existing NFT. */ + ttNFTOKEN_CANCEL_OFFER = 28, + + /** This transaction accepts an existing offer to buy or sell an existing NFT. */ + ttNFTOKEN_ACCEPT_OFFER = 29, + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/src/ripple/protocol/impl/ErrorCodes.cpp b/src/ripple/protocol/impl/ErrorCodes.cpp index 87fb2da2af3..e4a9acf4677 100644 --- a/src/ripple/protocol/impl/ErrorCodes.cpp +++ b/src/ripple/protocol/impl/ErrorCodes.cpp @@ -26,98 +26,77 @@ namespace RPC { namespace detail { +// clang-format off // Unordered array of ErrorInfos, so we don't have to maintain the list // ordering by hand. // // This array will be omitted from the object file; only the sorted version // will remain in the object file. But the string literals will remain. constexpr static ErrorInfo unorderedErrorInfos[]{ - {rpcACT_MALFORMED, "actMalformed", "Account malformed."}, - {rpcACT_NOT_FOUND, "actNotFound", "Account not found."}, - {rpcALREADY_MULTISIG, "alreadyMultisig", "Already multisigned."}, - {rpcALREADY_SINGLE_SIG, "alreadySingleSig", "Already single-signed."}, - {rpcAMENDMENT_BLOCKED, - "amendmentBlocked", - "Amendment blocked, need upgrade."}, - {rpcEXPIRED_VALIDATOR_LIST, "unlBlocked", "Validator list expired."}, - {rpcATX_DEPRECATED, - "deprecated", - "Use the new API or specify a ledger range."}, - {rpcBAD_KEY_TYPE, "badKeyType", "Bad key type."}, - {rpcBAD_FEATURE, "badFeature", "Feature unknown or invalid."}, - {rpcBAD_ISSUER, "badIssuer", "Issuer account malformed."}, - {rpcBAD_MARKET, "badMarket", "No such market."}, - {rpcBAD_SECRET, "badSecret", "Secret does not match account."}, - {rpcBAD_SEED, "badSeed", "Disallowed seed."}, - {rpcBAD_SYNTAX, "badSyntax", "Syntax error."}, - {rpcCHANNEL_MALFORMED, "channelMalformed", "Payment channel is malformed."}, - {rpcCHANNEL_AMT_MALFORMED, - "channelAmtMalformed", - "Payment channel amount is malformed."}, - {rpcCOMMAND_MISSING, "commandMissing", "Missing command entry."}, - {rpcDB_DESERIALIZATION, - "dbDeserialization", - "Database deserialization error."}, - {rpcDST_ACT_MALFORMED, - "dstActMalformed", - "Destination account is malformed."}, - {rpcDST_ACT_MISSING, "dstActMissing", "Destination account not provided."}, - {rpcDST_ACT_NOT_FOUND, "dstActNotFound", "Destination account not found."}, - {rpcDST_AMT_MALFORMED, - "dstAmtMalformed", - "Destination amount/currency/issuer is malformed."}, - {rpcDST_AMT_MISSING, - "dstAmtMissing", - "Destination amount/currency/issuer is missing."}, - {rpcDST_ISR_MALFORMED, - "dstIsrMalformed", - "Destination issuer is malformed."}, - {rpcEXCESSIVE_LGR_RANGE, "excessiveLgrRange", "Ledger range exceeds 1000."}, - {rpcFORBIDDEN, "forbidden", "Bad credentials."}, - {rpcFAILED_TO_FORWARD, - "failedToForward", - "Failed to forward request to p2p node"}, - {rpcHIGH_FEE, "highFee", "Current transaction fee exceeds your limit."}, - {rpcINTERNAL, "internal", "Internal error."}, - {rpcINVALID_LGR_RANGE, "invalidLgrRange", "Ledger range is invalid."}, - {rpcINVALID_PARAMS, "invalidParams", "Invalid parameters."}, - {rpcJSON_RPC, "json_rpc", "JSON-RPC transport error."}, - {rpcLGR_IDXS_INVALID, "lgrIdxsInvalid", "Ledger indexes invalid."}, - {rpcLGR_IDX_MALFORMED, "lgrIdxMalformed", "Ledger index malformed."}, - {rpcLGR_NOT_FOUND, "lgrNotFound", "Ledger not found."}, - {rpcLGR_NOT_VALIDATED, "lgrNotValidated", "Ledger not validated."}, - {rpcMASTER_DISABLED, "masterDisabled", "Master key is disabled."}, - {rpcNOT_ENABLED, "notEnabled", "Not enabled in configuration."}, - {rpcNOT_IMPL, "notImpl", "Not implemented."}, - {rpcNOT_READY, "notReady", "Not ready to handle this request."}, - {rpcNOT_SUPPORTED, "notSupported", "Operation not supported."}, - {rpcNO_CLOSED, "noClosed", "Closed ledger is unavailable."}, - {rpcNO_CURRENT, "noCurrent", "Current ledger is unavailable."}, - {rpcNOT_SYNCED, "notSynced", "Not synced to the network."}, - {rpcNO_EVENTS, "noEvents", "Current transport does not support events."}, - {rpcNO_NETWORK, "noNetwork", "Not synced to the network."}, - {rpcNO_PERMISSION, - "noPermission", - "You don't have permission for this command."}, - {rpcNO_PF_REQUEST, "noPathRequest", "No pathfinding request in progress."}, - {rpcPUBLIC_MALFORMED, "publicMalformed", "Public key is malformed."}, - {rpcREPORTING_UNSUPPORTED, - "reportingUnsupported", - "Requested operation not supported by reporting mode server"}, - {rpcSIGNING_MALFORMED, - "signingMalformed", - "Signing of transaction is malformed."}, - {rpcSLOW_DOWN, "slowDown", "You are placing too much load on the server."}, - {rpcSRC_ACT_MALFORMED, "srcActMalformed", "Source account is malformed."}, - {rpcSRC_ACT_MISSING, "srcActMissing", "Source account not provided."}, - {rpcSRC_ACT_NOT_FOUND, "srcActNotFound", "Source account not found."}, - {rpcSRC_CUR_MALFORMED, "srcCurMalformed", "Source currency is malformed."}, - {rpcSRC_ISR_MALFORMED, "srcIsrMalformed", "Source issuer is malformed."}, - {rpcSTREAM_MALFORMED, "malformedStream", "Stream malformed."}, - {rpcTOO_BUSY, "tooBusy", "The server is too busy to help you now."}, - {rpcTXN_NOT_FOUND, "txnNotFound", "Transaction not found."}, - {rpcUNKNOWN_COMMAND, "unknownCmd", "Unknown method."}, - {rpcSENDMAX_MALFORMED, "sendMaxMalformed", "SendMax amount malformed."}}; + {rpcACT_MALFORMED, "actMalformed", "Account malformed."}, + {rpcACT_NOT_FOUND, "actNotFound", "Account not found."}, + {rpcALREADY_MULTISIG, "alreadyMultisig", "Already multisigned."}, + {rpcALREADY_SINGLE_SIG, "alreadySingleSig", "Already single-signed."}, + {rpcAMENDMENT_BLOCKED, "amendmentBlocked", "Amendment blocked, need upgrade."}, + {rpcEXPIRED_VALIDATOR_LIST, "unlBlocked", "Validator list expired."}, + {rpcATX_DEPRECATED, "deprecated", "Use the new API or specify a ledger range."}, + {rpcBAD_KEY_TYPE, "badKeyType", "Bad key type."}, + {rpcBAD_FEATURE, "badFeature", "Feature unknown or invalid."}, + {rpcBAD_ISSUER, "badIssuer", "Issuer account malformed."}, + {rpcBAD_MARKET, "badMarket", "No such market."}, + {rpcBAD_SECRET, "badSecret", "Secret does not match account."}, + {rpcBAD_SEED, "badSeed", "Disallowed seed."}, + {rpcBAD_SYNTAX, "badSyntax", "Syntax error."}, + {rpcCHANNEL_MALFORMED, "channelMalformed", "Payment channel is malformed."}, + {rpcCHANNEL_AMT_MALFORMED, "channelAmtMalformed", "Payment channel amount is malformed."}, + {rpcCOMMAND_MISSING, "commandMissing", "Missing command entry."}, + {rpcDB_DESERIALIZATION, "dbDeserialization", "Database deserialization error."}, + {rpcDST_ACT_MALFORMED, "dstActMalformed", "Destination account is malformed."}, + {rpcDST_ACT_MISSING, "dstActMissing", "Destination account not provided."}, + {rpcDST_ACT_NOT_FOUND, "dstActNotFound", "Destination account not found."}, + {rpcDST_AMT_MALFORMED, "dstAmtMalformed", "Destination amount/currency/issuer is malformed."}, + {rpcDST_AMT_MISSING, "dstAmtMissing", "Destination amount/currency/issuer is missing."}, + {rpcDST_ISR_MALFORMED, "dstIsrMalformed", "Destination issuer is malformed."}, + {rpcEXCESSIVE_LGR_RANGE, "excessiveLgrRange", "Ledger range exceeds 1000."}, + {rpcFORBIDDEN, "forbidden", "Bad credentials."}, + {rpcFAILED_TO_FORWARD, "failedToForward", "Failed to forward request to p2p node"}, + {rpcHIGH_FEE, "highFee", "Current transaction fee exceeds your limit."}, + {rpcINTERNAL, "internal", "Internal error."}, + {rpcINVALID_LGR_RANGE, "invalidLgrRange", "Ledger range is invalid."}, + {rpcINVALID_PARAMS, "invalidParams", "Invalid parameters."}, + {rpcJSON_RPC, "json_rpc", "JSON-RPC transport error."}, + {rpcLGR_IDXS_INVALID, "lgrIdxsInvalid", "Ledger indexes invalid."}, + {rpcLGR_IDX_MALFORMED, "lgrIdxMalformed", "Ledger index malformed."}, + {rpcLGR_NOT_FOUND, "lgrNotFound", "Ledger not found."}, + {rpcLGR_NOT_VALIDATED, "lgrNotValidated", "Ledger not validated."}, + {rpcMASTER_DISABLED, "masterDisabled", "Master key is disabled."}, + {rpcNOT_ENABLED, "notEnabled", "Not enabled in configuration."}, + {rpcNOT_IMPL, "notImpl", "Not implemented."}, + {rpcNOT_READY, "notReady", "Not ready to handle this request."}, + {rpcNOT_SUPPORTED, "notSupported", "Operation not supported."}, + {rpcNO_CLOSED, "noClosed", "Closed ledger is unavailable."}, + {rpcNO_CURRENT, "noCurrent", "Current ledger is unavailable."}, + {rpcNOT_SYNCED, "notSynced", "Not synced to the network."}, + {rpcNO_EVENTS, "noEvents", "Current transport does not support events."}, + {rpcNO_NETWORK, "noNetwork", "Not synced to the network."}, + {rpcNO_PERMISSION, "noPermission", "You don't have permission for this command."}, + {rpcNO_PF_REQUEST, "noPathRequest", "No pathfinding request in progress."}, + {rpcPUBLIC_MALFORMED, "publicMalformed", "Public key is malformed."}, + {rpcREPORTING_UNSUPPORTED, "reportingUnsupported", "Requested operation not supported by reporting mode server"}, + {rpcSIGNING_MALFORMED, "signingMalformed", "Signing of transaction is malformed."}, + {rpcSLOW_DOWN, "slowDown", "You are placing too much load on the server."}, + {rpcSRC_ACT_MALFORMED, "srcActMalformed", "Source account is malformed."}, + {rpcSRC_ACT_MISSING, "srcActMissing", "Source account not provided."}, + {rpcSRC_ACT_NOT_FOUND, "srcActNotFound", "Source account not found."}, + {rpcSRC_CUR_MALFORMED, "srcCurMalformed", "Source currency is malformed."}, + {rpcSRC_ISR_MALFORMED, "srcIsrMalformed", "Source issuer is malformed."}, + {rpcSTREAM_MALFORMED, "malformedStream", "Stream malformed."}, + {rpcTOO_BUSY, "tooBusy", "The server is too busy to help you now."}, + {rpcTXN_NOT_FOUND, "txnNotFound", "Transaction not found."}, + {rpcUNKNOWN_COMMAND, "unknownCmd", "Unknown method."}, + {rpcSENDMAX_MALFORMED, "sendMaxMalformed", "SendMax amount malformed."}, + {rpcOBJECT_NOT_FOUND, "objectNotFound", "The requested object was not found."}}; +// clang-format on // C++ does not allow you to return an array from a function. You must // return an object which may in turn contain an array. The following diff --git a/src/ripple/protocol/impl/Feature.cpp b/src/ripple/protocol/impl/Feature.cpp index e1d82cb1b21..d713dc8c43b 100644 --- a/src/ripple/protocol/impl/Feature.cpp +++ b/src/ripple/protocol/impl/Feature.cpp @@ -437,6 +437,7 @@ REGISTER_FEATURE(FlowSortStrands, Supported::yes, DefaultVote::yes REGISTER_FIX (fixSTAmountCanonicalize, Supported::yes, DefaultVote::yes); REGISTER_FIX (fixRmSmallIncreasedQOffers, Supported::yes, DefaultVote::yes); REGISTER_FEATURE(CheckCashMakesTrustLine, Supported::yes, DefaultVote::no); +REGISTER_FEATURE(NonFungibleTokensV1, 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/protocol/impl/Indexes.cpp b/src/ripple/protocol/impl/Indexes.cpp index 6d7b7cc2222..69e7cc55d0f 100644 --- a/src/ripple/protocol/impl/Indexes.cpp +++ b/src/ripple/protocol/impl/Indexes.cpp @@ -20,7 +20,7 @@ #include #include #include -#include +#include #include #include @@ -60,6 +60,9 @@ enum class LedgerNameSpace : std::uint16_t { CHECK = 'C', DEPOSIT_PREAUTH = 'p', NEGATIVE_UNL = 'N', + NFTOKEN_OFFER = 'q', + NFTOKEN_BUY_OFFERS = 'h', + NFTOKEN_SELL_OFFERS = 'i', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -129,7 +132,7 @@ namespace keylet { Keylet account(AccountID const& id) noexcept { - return {ltACCOUNT_ROOT, indexHash(LedgerNameSpace::ACCOUNT, id)}; + return Keylet{ltACCOUNT_ROOT, indexHash(LedgerNameSpace::ACCOUNT, id)}; } Keylet @@ -325,6 +328,48 @@ payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept indexHash(LedgerNameSpace::XRP_PAYMENT_CHANNEL, src, dst, seq)}; } +Keylet +nftpage_min(AccountID const& owner) +{ + std::array buf{}; + std::memcpy(buf.data(), owner.data(), owner.size()); + return {ltNFTOKEN_PAGE, uint256{buf}}; +} + +Keylet +nftpage_max(AccountID const& owner) +{ + uint256 id = nft::pageMask; + std::memcpy(id.data(), owner.data(), owner.size()); + return {ltNFTOKEN_PAGE, id}; +} + +Keylet +nftpage(Keylet const& k, uint256 const& token) +{ + assert(k.type == ltNFTOKEN_PAGE); + return {ltNFTOKEN_PAGE, (k.key & ~nft::pageMask) + (token & nft::pageMask)}; +} + +Keylet +nftoffer(AccountID const& owner, std::uint32_t seq) +{ + return { + ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NFTOKEN_OFFER, owner, seq)}; +} + +Keylet +nft_buys(uint256 const& id) noexcept +{ + return {ltDIR_NODE, indexHash(LedgerNameSpace::NFTOKEN_BUY_OFFERS, id)}; +} + +Keylet +nft_sells(uint256 const& id) noexcept +{ + return {ltDIR_NODE, indexHash(LedgerNameSpace::NFTOKEN_SELL_OFFERS, id)}; +} + } // namespace keylet } // namespace ripple diff --git a/src/ripple/protocol/impl/InnerObjectFormats.cpp b/src/ripple/protocol/impl/InnerObjectFormats.cpp index 32d712b958a..c1b2acc87d2 100644 --- a/src/ripple/protocol/impl/InnerObjectFormats.cpp +++ b/src/ripple/protocol/impl/InnerObjectFormats.cpp @@ -51,6 +51,13 @@ InnerObjectFormats::InnerObjectFormats() {sfPublicKey, soeREQUIRED}, {sfFirstLedgerSequence, soeREQUIRED}, }); + + add(sfNFToken.jsonName.c_str(), + sfNFToken.getCode(), + { + {sfNFTokenID, soeREQUIRED}, + {sfURI, soeOPTIONAL}, + }); } InnerObjectFormats const& diff --git a/src/ripple/protocol/impl/LedgerFormats.cpp b/src/ripple/protocol/impl/LedgerFormats.cpp index b6feb382333..7d5cf9d21aa 100644 --- a/src/ripple/protocol/impl/LedgerFormats.cpp +++ b/src/ripple/protocol/impl/LedgerFormats.cpp @@ -17,149 +17,150 @@ */ //============================================================================== -#include #include #include -#include -#include #include namespace ripple { LedgerFormats::LedgerFormats() { + // clang-format off // Fields shared by all ledger formats: static const std::initializer_list commonFields{ - {sfLedgerIndex, soeOPTIONAL}, - {sfLedgerEntryType, soeREQUIRED}, - {sfFlags, soeREQUIRED}, + {sfLedgerIndex, soeOPTIONAL}, + {sfLedgerEntryType, soeREQUIRED}, + {sfFlags, soeREQUIRED}, }; add(jss::AccountRoot, ltACCOUNT_ROOT, { - {sfAccount, soeREQUIRED}, - {sfSequence, soeREQUIRED}, - {sfBalance, soeREQUIRED}, - {sfOwnerCount, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfAccountTxnID, soeOPTIONAL}, - {sfRegularKey, soeOPTIONAL}, - {sfEmailHash, soeOPTIONAL}, - {sfWalletLocator, soeOPTIONAL}, - {sfWalletSize, soeOPTIONAL}, - {sfMessageKey, soeOPTIONAL}, - {sfTransferRate, soeOPTIONAL}, - {sfDomain, soeOPTIONAL}, - {sfTickSize, soeOPTIONAL}, - {sfTicketCount, soeOPTIONAL}, + {sfAccount, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfBalance, soeREQUIRED}, + {sfOwnerCount, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfAccountTxnID, soeOPTIONAL}, + {sfRegularKey, soeOPTIONAL}, + {sfEmailHash, soeOPTIONAL}, + {sfWalletLocator, soeOPTIONAL}, + {sfWalletSize, soeOPTIONAL}, + {sfMessageKey, soeOPTIONAL}, + {sfTransferRate, soeOPTIONAL}, + {sfDomain, soeOPTIONAL}, + {sfTickSize, soeOPTIONAL}, + {sfTicketCount, soeOPTIONAL}, + {sfNFTokenMinter, soeOPTIONAL}, + {sfMintedNFTokens, soeDEFAULT}, + {sfBurnedNFTokens, soeDEFAULT}, }, commonFields); add(jss::DirectoryNode, ltDIR_NODE, { - {sfOwner, soeOPTIONAL}, // for owner directories - {sfTakerPaysCurrency, soeOPTIONAL}, // for order book directories - {sfTakerPaysIssuer, soeOPTIONAL}, // for order book directories - {sfTakerGetsCurrency, soeOPTIONAL}, // for order book directories - {sfTakerGetsIssuer, soeOPTIONAL}, // for order book directories - {sfExchangeRate, soeOPTIONAL}, // for order book directories - {sfIndexes, soeREQUIRED}, - {sfRootIndex, soeREQUIRED}, - {sfIndexNext, soeOPTIONAL}, - {sfIndexPrevious, soeOPTIONAL}, + {sfOwner, soeOPTIONAL}, // for owner directories + {sfTakerPaysCurrency, soeOPTIONAL}, // order book directories + {sfTakerPaysIssuer, soeOPTIONAL}, // order book directories + {sfTakerGetsCurrency, soeOPTIONAL}, // order book directories + {sfTakerGetsIssuer, soeOPTIONAL}, // order book directories + {sfExchangeRate, soeOPTIONAL}, // order book directories + {sfIndexes, soeREQUIRED}, + {sfRootIndex, soeREQUIRED}, + {sfIndexNext, soeOPTIONAL}, + {sfIndexPrevious, soeOPTIONAL}, + {sfNFTokenID, soeOPTIONAL}, }, commonFields); add(jss::Offer, ltOFFER, { - {sfAccount, soeREQUIRED}, - {sfSequence, soeREQUIRED}, - {sfTakerPays, soeREQUIRED}, - {sfTakerGets, soeREQUIRED}, - {sfBookDirectory, soeREQUIRED}, - {sfBookNode, soeREQUIRED}, - {sfOwnerNode, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfExpiration, soeOPTIONAL}, + {sfAccount, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfTakerPays, soeREQUIRED}, + {sfTakerGets, soeREQUIRED}, + {sfBookDirectory, soeREQUIRED}, + {sfBookNode, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfExpiration, soeOPTIONAL}, }, commonFields); add(jss::RippleState, ltRIPPLE_STATE, { - {sfBalance, soeREQUIRED}, - {sfLowLimit, soeREQUIRED}, - {sfHighLimit, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfLowNode, soeOPTIONAL}, - {sfLowQualityIn, soeOPTIONAL}, - {sfLowQualityOut, soeOPTIONAL}, - {sfHighNode, soeOPTIONAL}, - {sfHighQualityIn, soeOPTIONAL}, - {sfHighQualityOut, soeOPTIONAL}, + {sfBalance, soeREQUIRED}, + {sfLowLimit, soeREQUIRED}, + {sfHighLimit, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfLowNode, soeOPTIONAL}, + {sfLowQualityIn, soeOPTIONAL}, + {sfLowQualityOut, soeOPTIONAL}, + {sfHighNode, soeOPTIONAL}, + {sfHighQualityIn, soeOPTIONAL}, + {sfHighQualityOut, soeOPTIONAL}, }, commonFields); add(jss::Escrow, ltESCROW, { - {sfAccount, soeREQUIRED}, - {sfDestination, soeREQUIRED}, - {sfAmount, soeREQUIRED}, - {sfCondition, soeOPTIONAL}, - {sfCancelAfter, soeOPTIONAL}, - {sfFinishAfter, soeOPTIONAL}, - {sfSourceTag, soeOPTIONAL}, - {sfDestinationTag, soeOPTIONAL}, - {sfOwnerNode, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfDestinationNode, soeOPTIONAL}, + {sfAccount, soeREQUIRED}, + {sfDestination, soeREQUIRED}, + {sfAmount, soeREQUIRED}, + {sfCondition, soeOPTIONAL}, + {sfCancelAfter, soeOPTIONAL}, + {sfFinishAfter, soeOPTIONAL}, + {sfSourceTag, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfDestinationNode, soeOPTIONAL}, }, commonFields); add(jss::LedgerHashes, ltLEDGER_HASHES, { - {sfFirstLedgerSequence, - soeOPTIONAL}, // Remove if we do a ledger restart - {sfLastLedgerSequence, soeOPTIONAL}, - {sfHashes, soeREQUIRED}, + {sfFirstLedgerSequence, soeOPTIONAL}, + {sfLastLedgerSequence, soeOPTIONAL}, + {sfHashes, soeREQUIRED}, }, commonFields); add(jss::Amendments, ltAMENDMENTS, { - {sfAmendments, soeOPTIONAL}, // Enabled - {sfMajorities, soeOPTIONAL}, + {sfAmendments, soeOPTIONAL}, // Enabled + {sfMajorities, soeOPTIONAL}, }, commonFields); add(jss::FeeSettings, ltFEE_SETTINGS, { - {sfBaseFee, soeREQUIRED}, - {sfReferenceFeeUnits, soeREQUIRED}, - {sfReserveBase, soeREQUIRED}, - {sfReserveIncrement, soeREQUIRED}, + {sfBaseFee, soeREQUIRED}, + {sfReferenceFeeUnits, soeREQUIRED}, + {sfReserveBase, soeREQUIRED}, + {sfReserveIncrement, soeREQUIRED}, }, commonFields); add(jss::Ticket, ltTICKET, { - {sfAccount, soeREQUIRED}, - {sfOwnerNode, soeREQUIRED}, - {sfTicketSequence, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfTicketSequence, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, }, commonFields); @@ -168,72 +169,99 @@ LedgerFormats::LedgerFormats() add(jss::SignerList, ltSIGNER_LIST, { - {sfOwnerNode, soeREQUIRED}, - {sfSignerQuorum, soeREQUIRED}, - {sfSignerEntries, soeREQUIRED}, - {sfSignerListID, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfSignerQuorum, soeREQUIRED}, + {sfSignerEntries, soeREQUIRED}, + {sfSignerListID, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, }, commonFields); add(jss::PayChannel, ltPAYCHAN, { - {sfAccount, soeREQUIRED}, - {sfDestination, soeREQUIRED}, - {sfAmount, soeREQUIRED}, - {sfBalance, soeREQUIRED}, - {sfPublicKey, soeREQUIRED}, - {sfSettleDelay, soeREQUIRED}, - {sfExpiration, soeOPTIONAL}, - {sfCancelAfter, soeOPTIONAL}, - {sfSourceTag, soeOPTIONAL}, - {sfDestinationTag, soeOPTIONAL}, - {sfOwnerNode, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfDestinationNode, soeOPTIONAL}, + {sfAccount, soeREQUIRED}, + {sfDestination, soeREQUIRED}, + {sfAmount, soeREQUIRED}, + {sfBalance, soeREQUIRED}, + {sfPublicKey, soeREQUIRED}, + {sfSettleDelay, soeREQUIRED}, + {sfExpiration, soeOPTIONAL}, + {sfCancelAfter, soeOPTIONAL}, + {sfSourceTag, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfDestinationNode, soeOPTIONAL}, }, commonFields); add(jss::Check, ltCHECK, { - {sfAccount, soeREQUIRED}, - {sfDestination, soeREQUIRED}, - {sfSendMax, soeREQUIRED}, - {sfSequence, soeREQUIRED}, - {sfOwnerNode, soeREQUIRED}, - {sfDestinationNode, soeREQUIRED}, - {sfExpiration, soeOPTIONAL}, - {sfInvoiceID, soeOPTIONAL}, - {sfSourceTag, soeOPTIONAL}, - {sfDestinationTag, soeOPTIONAL}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfDestination, soeREQUIRED}, + {sfSendMax, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfDestinationNode, soeREQUIRED}, + {sfExpiration, soeOPTIONAL}, + {sfInvoiceID, soeOPTIONAL}, + {sfSourceTag, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, }, commonFields); add(jss::DepositPreauth, ltDEPOSIT_PREAUTH, { - {sfAccount, soeREQUIRED}, - {sfAuthorize, soeREQUIRED}, - {sfOwnerNode, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfAuthorize, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, }, commonFields); add(jss::NegativeUNL, ltNEGATIVE_UNL, { - {sfDisabledValidators, soeOPTIONAL}, - {sfValidatorToDisable, soeOPTIONAL}, - {sfValidatorToReEnable, soeOPTIONAL}, + {sfDisabledValidators, soeOPTIONAL}, + {sfValidatorToDisable, soeOPTIONAL}, + {sfValidatorToReEnable, soeOPTIONAL}, }, commonFields); + + add(jss::NFTokenPage, + ltNFTOKEN_PAGE, + { + {sfPreviousPageMin, soeOPTIONAL}, + {sfNextPageMin, soeOPTIONAL}, + {sfNFTokens, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED} + }, + commonFields); + + add(jss::NFTokenOffer, + ltNFTOKEN_OFFER, + { + {sfOwner, soeREQUIRED}, + {sfNFTokenID, soeREQUIRED}, + {sfAmount, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfNFTokenOfferNode, soeREQUIRED}, + {sfDestination, soeOPTIONAL}, + {sfExpiration, soeOPTIONAL}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED} + }, + commonFields); + // clang-format on } LedgerFormats const& diff --git a/src/ripple/protocol/impl/Rate2.cpp b/src/ripple/protocol/impl/Rate2.cpp index 3ca399427f5..340b6719bca 100644 --- a/src/ripple/protocol/impl/Rate2.cpp +++ b/src/ripple/protocol/impl/Rate2.cpp @@ -34,6 +34,15 @@ as_amount(Rate const& rate) } // namespace detail +namespace nft { +Rate +transferFeeAsRate(std::uint16_t fee) +{ + return Rate{static_cast(fee) * 10000}; +} + +} // namespace nft + STAmount multiply(STAmount const& amount, Rate const& rate) { diff --git a/src/ripple/protocol/impl/SField.cpp b/src/ripple/protocol/impl/SField.cpp index 679248dea6e..73098319b28 100644 --- a/src/ripple/protocol/impl/SField.cpp +++ b/src/ripple/protocol/impl/SField.cpp @@ -71,8 +71,8 @@ static SField::private_access_tag_t access; // SFields which, for historical reasons, do not follow naming conventions. SField const sfInvalid(access, -1); SField const sfGeneric(access, 0); -SField const sfHash(access, STI_HASH256, 257, "hash"); -SField const sfIndex(access, STI_HASH256, 258, "index"); +SField const sfHash(access, STI_UINT256, 257, "hash"); +SField const sfIndex(access, STI_UINT256, 258, "index"); // Untyped SFields CONSTRUCT_UNTYPED_SFIELD(sfLedgerEntry, "LedgerEntry", LEDGERENTRY, 257); @@ -94,6 +94,7 @@ CONSTRUCT_TYPED_SFIELD(sfHookResult, "HookResult", UINT8, CONSTRUCT_TYPED_SFIELD(sfLedgerEntryType, "LedgerEntryType", UINT16, 1, SField::sMD_Never); CONSTRUCT_TYPED_SFIELD(sfTransactionType, "TransactionType", UINT16, 2); CONSTRUCT_TYPED_SFIELD(sfSignerWeight, "SignerWeight", UINT16, 3); +CONSTRUCT_TYPED_SFIELD(sfTransferFee, "TransferFee", UINT16, 4); // 16-bit integers (uncommon) CONSTRUCT_TYPED_SFIELD(sfVersion, "Version", UINT16, 16); @@ -144,10 +145,13 @@ CONSTRUCT_TYPED_SFIELD(sfSignerListID, "SignerListID", UINT32, CONSTRUCT_TYPED_SFIELD(sfSettleDelay, "SettleDelay", UINT32, 39); CONSTRUCT_TYPED_SFIELD(sfTicketCount, "TicketCount", UINT32, 40); CONSTRUCT_TYPED_SFIELD(sfTicketSequence, "TicketSequence", UINT32, 41); +CONSTRUCT_TYPED_SFIELD(sfNFTokenTaxon, "NFTokenTaxon", UINT32, 42); +CONSTRUCT_TYPED_SFIELD(sfMintedNFTokens, "MintedNFTokens", UINT32, 43); +CONSTRUCT_TYPED_SFIELD(sfBurnedNFTokens, "BurnedNFTokens", UINT32, 44); CONSTRUCT_TYPED_SFIELD(sfHookStateCount, "HookStateCount", UINT32, 45); CONSTRUCT_TYPED_SFIELD(sfEmitGeneration, "EmitGeneration", UINT32, 46); -// 64-bit integers +// 64-bit integers (common) CONSTRUCT_TYPED_SFIELD(sfIndexNext, "IndexNext", UINT64, 1); CONSTRUCT_TYPED_SFIELD(sfIndexPrevious, "IndexPrevious", UINT64, 2); CONSTRUCT_TYPED_SFIELD(sfBookNode, "BookNode", UINT64, 3); @@ -159,50 +163,58 @@ CONSTRUCT_TYPED_SFIELD(sfHighNode, "HighNode", UINT64, CONSTRUCT_TYPED_SFIELD(sfDestinationNode, "DestinationNode", UINT64, 9); CONSTRUCT_TYPED_SFIELD(sfCookie, "Cookie", UINT64, 10); CONSTRUCT_TYPED_SFIELD(sfServerVersion, "ServerVersion", UINT64, 11); +CONSTRUCT_TYPED_SFIELD(sfNFTokenOfferNode, "NFTokenOfferNode", UINT64, 12); CONSTRUCT_TYPED_SFIELD(sfEmitBurden, "EmitBurden", UINT64, 13); + +// 64-bit integers (uncommon) CONSTRUCT_TYPED_SFIELD(sfHookOn, "HookOn", UINT64, 16); CONSTRUCT_TYPED_SFIELD(sfHookInstructionCount, "HookInstructionCount", UINT64, 17); CONSTRUCT_TYPED_SFIELD(sfHookReturnCode, "HookReturnCode", UINT64, 18); CONSTRUCT_TYPED_SFIELD(sfReferenceCount, "ReferenceCount", UINT64, 19); // 128-bit -CONSTRUCT_TYPED_SFIELD(sfEmailHash, "EmailHash", HASH128, 1); +CONSTRUCT_TYPED_SFIELD(sfEmailHash, "EmailHash", UINT128, 1); // 160-bit (common) -CONSTRUCT_TYPED_SFIELD(sfTakerPaysCurrency, "TakerPaysCurrency", HASH160, 1); -CONSTRUCT_TYPED_SFIELD(sfTakerPaysIssuer, "TakerPaysIssuer", HASH160, 2); -CONSTRUCT_TYPED_SFIELD(sfTakerGetsCurrency, "TakerGetsCurrency", HASH160, 3); -CONSTRUCT_TYPED_SFIELD(sfTakerGetsIssuer, "TakerGetsIssuer", HASH160, 4); +CONSTRUCT_TYPED_SFIELD(sfTakerPaysCurrency, "TakerPaysCurrency", UINT160, 1); +CONSTRUCT_TYPED_SFIELD(sfTakerPaysIssuer, "TakerPaysIssuer", UINT160, 2); +CONSTRUCT_TYPED_SFIELD(sfTakerGetsCurrency, "TakerGetsCurrency", UINT160, 3); +CONSTRUCT_TYPED_SFIELD(sfTakerGetsIssuer, "TakerGetsIssuer", UINT160, 4); // 256-bit (common) -CONSTRUCT_TYPED_SFIELD(sfLedgerHash, "LedgerHash", HASH256, 1); -CONSTRUCT_TYPED_SFIELD(sfParentHash, "ParentHash", HASH256, 2); -CONSTRUCT_TYPED_SFIELD(sfTransactionHash, "TransactionHash", HASH256, 3); -CONSTRUCT_TYPED_SFIELD(sfAccountHash, "AccountHash", HASH256, 4); -CONSTRUCT_TYPED_SFIELD(sfPreviousTxnID, "PreviousTxnID", HASH256, 5, SField::sMD_DeleteFinal); -CONSTRUCT_TYPED_SFIELD(sfLedgerIndex, "LedgerIndex", HASH256, 6); -CONSTRUCT_TYPED_SFIELD(sfWalletLocator, "WalletLocator", HASH256, 7); -CONSTRUCT_TYPED_SFIELD(sfRootIndex, "RootIndex", HASH256, 8, SField::sMD_Always); -CONSTRUCT_TYPED_SFIELD(sfAccountTxnID, "AccountTxnID", HASH256, 9); -CONSTRUCT_TYPED_SFIELD(sfEmitParentTxnID, "EmitParentTxnID", HASH256, 11); -CONSTRUCT_TYPED_SFIELD(sfEmitNonce, "EmitNonce", HASH256, 12); -CONSTRUCT_TYPED_SFIELD(sfEmitHookHash, "EmitHookHash", HASH256, 13); +CONSTRUCT_TYPED_SFIELD(sfLedgerHash, "LedgerHash", UINT256, 1); +CONSTRUCT_TYPED_SFIELD(sfParentHash, "ParentHash", UINT256, 2); +CONSTRUCT_TYPED_SFIELD(sfTransactionHash, "TransactionHash", UINT256, 3); +CONSTRUCT_TYPED_SFIELD(sfAccountHash, "AccountHash", UINT256, 4); +CONSTRUCT_TYPED_SFIELD(sfPreviousTxnID, "PreviousTxnID", UINT256, 5, SField::sMD_DeleteFinal); +CONSTRUCT_TYPED_SFIELD(sfLedgerIndex, "LedgerIndex", UINT256, 6); +CONSTRUCT_TYPED_SFIELD(sfWalletLocator, "WalletLocator", UINT256, 7); +CONSTRUCT_TYPED_SFIELD(sfRootIndex, "RootIndex", UINT256, 8, SField::sMD_Always); +CONSTRUCT_TYPED_SFIELD(sfAccountTxnID, "AccountTxnID", UINT256, 9); +CONSTRUCT_TYPED_SFIELD(sfNFTokenID, "NFTokenID", UINT256, 10); +CONSTRUCT_TYPED_SFIELD(sfEmitParentTxnID, "EmitParentTxnID", UINT256, 11); +CONSTRUCT_TYPED_SFIELD(sfEmitNonce, "EmitNonce", UINT256, 12); +CONSTRUCT_TYPED_SFIELD(sfEmitHookHash, "EmitHookHash", UINT256, 13); // 256-bit (uncommon) -CONSTRUCT_TYPED_SFIELD(sfBookDirectory, "BookDirectory", HASH256, 16); -CONSTRUCT_TYPED_SFIELD(sfInvoiceID, "InvoiceID", HASH256, 17); -CONSTRUCT_TYPED_SFIELD(sfNickname, "Nickname", HASH256, 18); -CONSTRUCT_TYPED_SFIELD(sfAmendment, "Amendment", HASH256, 19); +CONSTRUCT_TYPED_SFIELD(sfBookDirectory, "BookDirectory", UINT256, 16); +CONSTRUCT_TYPED_SFIELD(sfInvoiceID, "InvoiceID", UINT256, 17); +CONSTRUCT_TYPED_SFIELD(sfNickname, "Nickname", UINT256, 18); +CONSTRUCT_TYPED_SFIELD(sfAmendment, "Amendment", UINT256, 19); // 20 is currently unused -CONSTRUCT_TYPED_SFIELD(sfDigest, "Digest", HASH256, 21); -CONSTRUCT_TYPED_SFIELD(sfChannel, "Channel", HASH256, 22); -CONSTRUCT_TYPED_SFIELD(sfConsensusHash, "ConsensusHash", HASH256, 23); -CONSTRUCT_TYPED_SFIELD(sfCheckID, "CheckID", HASH256, 24); -CONSTRUCT_TYPED_SFIELD(sfValidatedHash, "ValidatedHash", HASH256, 25); -CONSTRUCT_TYPED_SFIELD(sfHookStateKey, "HookStateKey", HASH256, 30); -CONSTRUCT_TYPED_SFIELD(sfHookHash, "HookHash", HASH256, 31); -CONSTRUCT_TYPED_SFIELD(sfHookNamespace, "HookNamespace", HASH256, 32); -CONSTRUCT_TYPED_SFIELD(sfHookSetTxnID, "HookSetTxnID", HASH256, 33); +CONSTRUCT_TYPED_SFIELD(sfDigest, "Digest", UINT256, 21); +CONSTRUCT_TYPED_SFIELD(sfChannel, "Channel", UINT256, 22); +CONSTRUCT_TYPED_SFIELD(sfConsensusHash, "ConsensusHash", UINT256, 23); +CONSTRUCT_TYPED_SFIELD(sfCheckID, "CheckID", UINT256, 24); +CONSTRUCT_TYPED_SFIELD(sfValidatedHash, "ValidatedHash", UINT256, 25); +CONSTRUCT_TYPED_SFIELD(sfPreviousPageMin, "PreviousPageMin", UINT256, 26); +CONSTRUCT_TYPED_SFIELD(sfNextPageMin, "NextPageMin", UINT256, 27); +CONSTRUCT_TYPED_SFIELD(sfNFTokenBuyOffer, "NFTokenBuyOffer", UINT256, 28); +CONSTRUCT_TYPED_SFIELD(sfNFTokenSellOffer, "NFTokenSellOffer", UINT256, 29); +CONSTRUCT_TYPED_SFIELD(sfHookStateKey, "HookStateKey", UINT256, 30); +CONSTRUCT_TYPED_SFIELD(sfHookHash, "HookHash", UINT256, 31); +CONSTRUCT_TYPED_SFIELD(sfHookNamespace, "HookNamespace", UINT256, 32); +CONSTRUCT_TYPED_SFIELD(sfHookSetTxnID, "HookSetTxnID", UINT256, 33); // currency amount (common) CONSTRUCT_TYPED_SFIELD(sfAmount, "Amount", AMOUNT, 1); @@ -220,13 +232,14 @@ CONSTRUCT_TYPED_SFIELD(sfDeliverMin, "DeliverMin", AMOUNT, CONSTRUCT_TYPED_SFIELD(sfMinimumOffer, "MinimumOffer", AMOUNT, 16); CONSTRUCT_TYPED_SFIELD(sfRippleEscrow, "RippleEscrow", AMOUNT, 17); CONSTRUCT_TYPED_SFIELD(sfDeliveredAmount, "DeliveredAmount", AMOUNT, 18); +CONSTRUCT_TYPED_SFIELD(sfNFTokenBrokerFee, "NFTokenBrokerFee", AMOUNT, 19); // variable length (common) CONSTRUCT_TYPED_SFIELD(sfPublicKey, "PublicKey", VL, 1); CONSTRUCT_TYPED_SFIELD(sfMessageKey, "MessageKey", VL, 2); CONSTRUCT_TYPED_SFIELD(sfSigningPubKey, "SigningPubKey", VL, 3); CONSTRUCT_TYPED_SFIELD(sfTxnSignature, "TxnSignature", VL, 4, SField::sMD_Default, SField::notSigning); -// Was 5 used and then obsoleted? +CONSTRUCT_TYPED_SFIELD(sfURI, "URI", VL, 5); CONSTRUCT_TYPED_SFIELD(sfSignature, "Signature", VL, 6, SField::sMD_Default, SField::notSigning); CONSTRUCT_TYPED_SFIELD(sfDomain, "Domain", VL, 7); CONSTRUCT_TYPED_SFIELD(sfFundCode, "FundCode", VL, 8); @@ -258,7 +271,8 @@ CONSTRUCT_TYPED_SFIELD(sfAuthorize, "Authorize", ACCOUNT, CONSTRUCT_TYPED_SFIELD(sfUnauthorize, "Unauthorize", ACCOUNT, 6); // 7 is currently unused CONSTRUCT_TYPED_SFIELD(sfRegularKey, "RegularKey", ACCOUNT, 8); -CONSTRUCT_TYPED_SFIELD(sfEmitCallback, "EmitCallback", ACCOUNT, 10); +CONSTRUCT_TYPED_SFIELD(sfNFTokenMinter, "NFTokenMinter", ACCOUNT, 9); +CONSTRUCT_TYPED_SFIELD(sfEmitCallback, "EmitCallback", ACCOUNT, 10); // account (uncommon) CONSTRUCT_TYPED_SFIELD(sfHookAccount, "HookAccount", ACCOUNT, 16); @@ -267,6 +281,7 @@ CONSTRUCT_TYPED_SFIELD(sfHookAccount, "HookAccount", ACCOUNT, CONSTRUCT_TYPED_SFIELD(sfIndexes, "Indexes", VECTOR256, 1, SField::sMD_Never); CONSTRUCT_TYPED_SFIELD(sfHashes, "Hashes", VECTOR256, 2); CONSTRUCT_TYPED_SFIELD(sfAmendments, "Amendments", VECTOR256, 3); +CONSTRUCT_TYPED_SFIELD(sfNFTokenOffers, "NFTokenOffers", VECTOR256, 4); // path set CONSTRUCT_UNTYPED_SFIELD(sfPaths, "Paths", PATHSET, 1); @@ -283,6 +298,7 @@ CONSTRUCT_UNTYPED_SFIELD(sfNewFields, "NewFields", OBJECT, CONSTRUCT_UNTYPED_SFIELD(sfTemplateEntry, "TemplateEntry", OBJECT, 9); CONSTRUCT_UNTYPED_SFIELD(sfMemo, "Memo", OBJECT, 10); CONSTRUCT_UNTYPED_SFIELD(sfSignerEntry, "SignerEntry", OBJECT, 11); +CONSTRUCT_UNTYPED_SFIELD(sfNFToken, "NFToken", OBJECT, 12); CONSTRUCT_UNTYPED_SFIELD(sfEmitDetails, "EmitDetails", OBJECT, 13); CONSTRUCT_UNTYPED_SFIELD(sfHook, "Hook", OBJECT, 14); @@ -307,6 +323,7 @@ CONSTRUCT_UNTYPED_SFIELD(sfNecessary, "Necessary", ARRAY, CONSTRUCT_UNTYPED_SFIELD(sfSufficient, "Sufficient", ARRAY, 7); CONSTRUCT_UNTYPED_SFIELD(sfAffectedNodes, "AffectedNodes", ARRAY, 8); CONSTRUCT_UNTYPED_SFIELD(sfMemos, "Memos", ARRAY, 9); +CONSTRUCT_UNTYPED_SFIELD(sfNFTokens, "NFTokens", ARRAY, 10); CONSTRUCT_UNTYPED_SFIELD(sfHooks, "Hooks", ARRAY, 11); // array of objects (uncommon) diff --git a/src/ripple/protocol/impl/STObject.cpp b/src/ripple/protocol/impl/STObject.cpp index 0d550073800..f2d5fdfe3ab 100644 --- a/src/ripple/protocol/impl/STObject.cpp +++ b/src/ripple/protocol/impl/STObject.cpp @@ -570,19 +570,19 @@ STObject::getFieldU64(SField const& field) const uint128 STObject::getFieldH128(SField const& field) const { - return getFieldByValue(field); + return getFieldByValue(field); } uint160 STObject::getFieldH160(SField const& field) const { - return getFieldByValue(field); + return getFieldByValue(field); } uint256 STObject::getFieldH256(SField const& field) const { - return getFieldByValue(field); + return getFieldByValue(field); } AccountID @@ -670,13 +670,13 @@ STObject::setFieldU64(SField const& field, std::uint64_t v) void STObject::setFieldH128(SField const& field, uint128 const& v) { - setFieldUsingSetValue(field, v); + setFieldUsingSetValue(field, v); } void STObject::setFieldH256(SField const& field, uint256 const& v) { - setFieldUsingSetValue(field, v); + setFieldUsingSetValue(field, v); } void diff --git a/src/ripple/protocol/impl/STParsedJSON.cpp b/src/ripple/protocol/impl/STParsedJSON.cpp index b1ea0583744..6473f80deee 100644 --- a/src/ripple/protocol/impl/STParsedJSON.cpp +++ b/src/ripple/protocol/impl/STParsedJSON.cpp @@ -425,7 +425,7 @@ parseLeaf( break; - case STI_HASH128: { + case STI_UINT128: { if (!value.isString()) { error = bad_type(json_name, fieldName); @@ -445,11 +445,11 @@ parseLeaf( num.zero(); } - ret = detail::make_stvar(field, num); + ret = detail::make_stvar(field, num); break; } - case STI_HASH160: { + case STI_UINT160: { if (!value.isString()) { error = bad_type(json_name, fieldName); @@ -469,11 +469,11 @@ parseLeaf( num.zero(); } - ret = detail::make_stvar(field, num); + ret = detail::make_stvar(field, num); break; } - case STI_HASH256: { + case STI_UINT256: { if (!value.isString()) { error = bad_type(json_name, fieldName); @@ -493,7 +493,7 @@ parseLeaf( num.zero(); } - ret = detail::make_stvar(field, num); + ret = detail::make_stvar(field, num); break; } @@ -860,8 +860,9 @@ parseObject( return data; } - catch (STObject::FieldErr const&) + catch (STObject::FieldErr const& e) { + std::cerr << "template_mismatch: " << e.what() << "\n"; error = template_mismatch(inName); } catch (std::exception const&) diff --git a/src/ripple/protocol/impl/STVar.cpp b/src/ripple/protocol/impl/STVar.cpp index 6d0442008ff..0628c95daef 100644 --- a/src/ripple/protocol/impl/STVar.cpp +++ b/src/ripple/protocol/impl/STVar.cpp @@ -130,14 +130,14 @@ STVar::STVar(SerialIter& sit, SField const& name, int depth) case STI_AMOUNT: construct(sit, name); return; - case STI_HASH128: - construct(sit, name); + case STI_UINT128: + construct(sit, name); return; - case STI_HASH160: - construct(sit, name); + case STI_UINT160: + construct(sit, name); return; - case STI_HASH256: - construct(sit, name); + case STI_UINT256: + construct(sit, name); return; case STI_VECTOR256: construct(sit, name); @@ -185,14 +185,14 @@ STVar::STVar(SerializedTypeID id, SField const& name) case STI_AMOUNT: construct(name); return; - case STI_HASH128: - construct(name); + case STI_UINT128: + construct(name); return; - case STI_HASH160: - construct(name); + case STI_UINT160: + construct(name); return; - case STI_HASH256: - construct(name); + case STI_UINT256: + construct(name); return; case STI_VECTOR256: construct(name); diff --git a/src/ripple/protocol/impl/TER.cpp b/src/ripple/protocol/impl/TER.cpp index cb8cd7e898b..c660b1cea3f 100644 --- a/src/ripple/protocol/impl/TER.cpp +++ b/src/ripple/protocol/impl/TER.cpp @@ -42,65 +42,74 @@ transResults() TERUnderlyingType, std::pair> const results { - MAKE_ERROR(tecCLAIM, "Fee claimed. Sequence used. No action."), - MAKE_ERROR(tecDIR_FULL, "Can not add entry to full directory."), - MAKE_ERROR(tecFAILED_PROCESSING, "Failed to correctly process transaction."), - MAKE_ERROR(tecINSUF_RESERVE_LINE, "Insufficient reserve to add trust line."), - MAKE_ERROR(tecINSUF_RESERVE_OFFER, "Insufficient reserve to create offer."), - MAKE_ERROR(tecNO_DST, "Destination does not exist. Send XRP to create it."), - MAKE_ERROR(tecNO_DST_INSUF_XRP, "Destination does not exist. Too little XRP sent to create it."), - MAKE_ERROR(tecNO_LINE_INSUF_RESERVE, "No such line. Too little reserve to create it."), - MAKE_ERROR(tecNO_LINE_REDUNDANT, "Can't set non-existent line to default."), - MAKE_ERROR(tecPATH_DRY, "Path could not send partial amount."), - MAKE_ERROR(tecPATH_PARTIAL, "Path could not send full amount."), - MAKE_ERROR(tecNO_ALTERNATIVE_KEY, "The operation would remove the ability to sign transactions with the account."), - MAKE_ERROR(tecNO_REGULAR_KEY, "Regular key is not set."), - MAKE_ERROR(tecOVERSIZE, "Object exceeded serialization limits."), - MAKE_ERROR(tecUNFUNDED, "Not enough XRP to satisfy the reserve requirement."), - MAKE_ERROR(tecUNFUNDED_ADD, "DEPRECATED."), - MAKE_ERROR(tecUNFUNDED_OFFER, "Insufficient balance to fund created offer."), - MAKE_ERROR(tecUNFUNDED_PAYMENT, "Insufficient XRP balance to send."), - MAKE_ERROR(tecOWNERS, "Non-zero owner count."), - MAKE_ERROR(tecNO_ISSUER, "Issuer account does not exist."), - MAKE_ERROR(tecNO_AUTH, "Not authorized to hold asset."), - MAKE_ERROR(tecNO_LINE, "No such line."), - MAKE_ERROR(tecINSUFF_FEE, "Insufficient balance to pay fee."), - MAKE_ERROR(tecFROZEN, "Asset is frozen."), - MAKE_ERROR(tecNO_TARGET, "Target account does not exist."), - MAKE_ERROR(tecNO_PERMISSION, "No permission to perform requested operation."), - MAKE_ERROR(tecNO_ENTRY, "No matching entry found."), - MAKE_ERROR(tecINSUFFICIENT_RESERVE, "Insufficient reserve to complete requested operation."), - MAKE_ERROR(tecNEED_MASTER_KEY, "The operation requires the use of the Master Key."), - MAKE_ERROR(tecDST_TAG_NEEDED, "A destination tag is required."), - MAKE_ERROR(tecINTERNAL, "An internal error has occurred during processing."), - MAKE_ERROR(tecCRYPTOCONDITION_ERROR, "Malformed, invalid, or mismatched conditional or fulfillment."), - MAKE_ERROR(tecINVARIANT_FAILED, "One or more invariants for the transaction were not satisfied."), - MAKE_ERROR(tecEXPIRED, "Expiration time is passed."), - MAKE_ERROR(tecDUPLICATE, "Ledger object already exists."), - MAKE_ERROR(tecKILLED, "FillOrKill offer killed."), - MAKE_ERROR(tecHAS_OBLIGATIONS, "The account cannot be deleted since it has obligations."), - MAKE_ERROR(tecTOO_SOON, "It is too early to attempt the requested operation. Please wait."), - - MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."), - MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."), - MAKE_ERROR(tefBAD_AUTH, "Transaction's public key is not authorized."), - MAKE_ERROR(tefBAD_LEDGER, "Ledger in unexpected state."), - MAKE_ERROR(tefBAD_QUORUM, "Signatures provided do not meet the quorum."), - MAKE_ERROR(tefBAD_SIGNATURE, "A signature is provided for a non-signer."), - MAKE_ERROR(tefCREATED, "Can't add an already created account."), - MAKE_ERROR(tefEXCEPTION, "Unexpected program state."), - MAKE_ERROR(tefFAILURE, "Failed to apply."), - MAKE_ERROR(tefINTERNAL, "Internal error."), - MAKE_ERROR(tefMASTER_DISABLED, "Master key is disabled."), - MAKE_ERROR(tefMAX_LEDGER, "Ledger sequence too high."), - MAKE_ERROR(tefNO_AUTH_REQUIRED, "Auth is not required."), - MAKE_ERROR(tefNOT_MULTI_SIGNING, "Account has no appropriate list of multi-signers."), - MAKE_ERROR(tefPAST_SEQ, "This sequence number has already passed."), - MAKE_ERROR(tefWRONG_PRIOR, "This previous transaction does not match."), - MAKE_ERROR(tefBAD_AUTH_MASTER, "Auth for unclaimed account needs correct master key."), - MAKE_ERROR(tefINVARIANT_FAILED, "Fee claim violated invariants for the transaction."), - MAKE_ERROR(tefTOO_BIG, "Transaction affects too many items."), - MAKE_ERROR(tefNO_TICKET, "Ticket is not in ledger."), + MAKE_ERROR(tecCLAIM, "Fee claimed. Sequence used. No action."), + MAKE_ERROR(tecDIR_FULL, "Can not add entry to full directory."), + MAKE_ERROR(tecFAILED_PROCESSING, "Failed to correctly process transaction."), + MAKE_ERROR(tecINSUF_RESERVE_LINE, "Insufficient reserve to add trust line."), + MAKE_ERROR(tecINSUF_RESERVE_OFFER, "Insufficient reserve to create offer."), + MAKE_ERROR(tecNO_DST, "Destination does not exist. Send XRP to create it."), + MAKE_ERROR(tecNO_DST_INSUF_XRP, "Destination does not exist. Too little XRP sent to create it."), + MAKE_ERROR(tecNO_LINE_INSUF_RESERVE, "No such line. Too little reserve to create it."), + MAKE_ERROR(tecNO_LINE_REDUNDANT, "Can't set non-existent line to default."), + MAKE_ERROR(tecPATH_DRY, "Path could not send partial amount."), + MAKE_ERROR(tecPATH_PARTIAL, "Path could not send full amount."), + MAKE_ERROR(tecNO_ALTERNATIVE_KEY, "The operation would remove the ability to sign transactions with the account."), + MAKE_ERROR(tecNO_REGULAR_KEY, "Regular key is not set."), + MAKE_ERROR(tecOVERSIZE, "Object exceeded serialization limits."), + MAKE_ERROR(tecUNFUNDED, "Not enough XRP to satisfy the reserve requirement."), + MAKE_ERROR(tecUNFUNDED_ADD, "DEPRECATED."), + MAKE_ERROR(tecUNFUNDED_OFFER, "Insufficient balance to fund created offer."), + MAKE_ERROR(tecUNFUNDED_PAYMENT, "Insufficient XRP balance to send."), + MAKE_ERROR(tecOWNERS, "Non-zero owner count."), + MAKE_ERROR(tecNO_ISSUER, "Issuer account does not exist."), + MAKE_ERROR(tecNO_AUTH, "Not authorized to hold asset."), + MAKE_ERROR(tecNO_LINE, "No such line."), + MAKE_ERROR(tecINSUFF_FEE, "Insufficient balance to pay fee."), + MAKE_ERROR(tecFROZEN, "Asset is frozen."), + MAKE_ERROR(tecNO_TARGET, "Target account does not exist."), + MAKE_ERROR(tecNO_PERMISSION, "No permission to perform requested operation."), + MAKE_ERROR(tecNO_ENTRY, "No matching entry found."), + MAKE_ERROR(tecINSUFFICIENT_RESERVE, "Insufficient reserve to complete requested operation."), + MAKE_ERROR(tecNEED_MASTER_KEY, "The operation requires the use of the Master Key."), + MAKE_ERROR(tecDST_TAG_NEEDED, "A destination tag is required."), + MAKE_ERROR(tecINTERNAL, "An internal error has occurred during processing."), + MAKE_ERROR(tecCRYPTOCONDITION_ERROR, "Malformed, invalid, or mismatched conditional or fulfillment."), + MAKE_ERROR(tecINVARIANT_FAILED, "One or more invariants for the transaction were not satisfied."), + MAKE_ERROR(tecEXPIRED, "Expiration time is passed."), + MAKE_ERROR(tecDUPLICATE, "Ledger object already exists."), + MAKE_ERROR(tecKILLED, "FillOrKill offer killed."), + MAKE_ERROR(tecHAS_OBLIGATIONS, "The account cannot be deleted since it has obligations."), + MAKE_ERROR(tecTOO_SOON, "It is too early to attempt the requested operation. Please wait."), + MAKE_ERROR(tecMAX_SEQUENCE_REACHED, "The maximum sequence number was reached."), + MAKE_ERROR(tecNO_SUITABLE_NFTOKEN_PAGE, "A suitable NFToken page could not be located."), + MAKE_ERROR(tecNFTOKEN_BUY_SELL_MISMATCH, "The 'Buy' and 'Sell' NFToken offers are mismatched."), + MAKE_ERROR(tecNFTOKEN_OFFER_TYPE_MISMATCH, "The type of NFToken offer is incorrect."), + MAKE_ERROR(tecCANT_ACCEPT_OWN_NFTOKEN_OFFER, "An NFToken offer cannot be claimed by its owner."), + MAKE_ERROR(tecINSUFFICIENT_FUNDS, "Not enough funds available to complete requested transaction."), + MAKE_ERROR(tecOBJECT_NOT_FOUND, "A requested object could not be located."), + MAKE_ERROR(tecINSUFFICIENT_PAYMENT, "The payment is not sufficient."), + + MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."), + MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."), + MAKE_ERROR(tefBAD_AUTH, "Transaction's public key is not authorized."), + MAKE_ERROR(tefBAD_LEDGER, "Ledger in unexpected state."), + MAKE_ERROR(tefBAD_QUORUM, "Signatures provided do not meet the quorum."), + MAKE_ERROR(tefBAD_SIGNATURE, "A signature is provided for a non-signer."), + MAKE_ERROR(tefCREATED, "Can't add an already created account."), + MAKE_ERROR(tefEXCEPTION, "Unexpected program state."), + MAKE_ERROR(tefFAILURE, "Failed to apply."), + MAKE_ERROR(tefINTERNAL, "Internal error."), + MAKE_ERROR(tefMASTER_DISABLED, "Master key is disabled."), + MAKE_ERROR(tefMAX_LEDGER, "Ledger sequence too high."), + MAKE_ERROR(tefNO_AUTH_REQUIRED, "Auth is not required."), + MAKE_ERROR(tefNOT_MULTI_SIGNING, "Account has no appropriate list of multi-signers."), + MAKE_ERROR(tefPAST_SEQ, "This sequence number has already passed."), + MAKE_ERROR(tefWRONG_PRIOR, "This previous transaction does not match."), + MAKE_ERROR(tefBAD_AUTH_MASTER, "Auth for unclaimed account needs correct master key."), + MAKE_ERROR(tefINVARIANT_FAILED, "Fee claim violated invariants for the transaction."), + MAKE_ERROR(tefTOO_BIG, "Transaction affects too many items."), + MAKE_ERROR(tefNO_TICKET, "Ticket is not in ledger."), + MAKE_ERROR(tefNFTOKEN_IS_NOT_TRANSFERABLE, "The specified NFToken is not transferable."), MAKE_ERROR(telLOCAL_ERROR, "Local failure."), MAKE_ERROR(telBAD_DOMAIN, "Domain too long."), @@ -116,43 +125,44 @@ transResults() MAKE_ERROR(telCAN_NOT_QUEUE_FEE, "Can not queue at this time: fee insufficient to replace queued transaction."), MAKE_ERROR(telCAN_NOT_QUEUE_FULL, "Can not queue at this time: queue is full."), - MAKE_ERROR(temMALFORMED, "Malformed transaction."), - MAKE_ERROR(temBAD_AMOUNT, "Can only send positive amounts."), - MAKE_ERROR(temBAD_CURRENCY, "Malformed: Bad currency."), - MAKE_ERROR(temBAD_EXPIRATION, "Malformed: Bad expiration."), - MAKE_ERROR(temBAD_FEE, "Invalid fee, negative or not XRP."), - MAKE_ERROR(temBAD_ISSUER, "Malformed: Bad issuer."), - MAKE_ERROR(temBAD_LIMIT, "Limits must be non-negative."), - MAKE_ERROR(temBAD_OFFER, "Malformed: Bad offer."), - MAKE_ERROR(temBAD_PATH, "Malformed: Bad path."), - MAKE_ERROR(temBAD_PATH_LOOP, "Malformed: Loop in path."), - MAKE_ERROR(temBAD_QUORUM, "Malformed: Quorum is unreachable."), - MAKE_ERROR(temBAD_REGKEY, "Malformed: Regular key cannot be same as master key."), - MAKE_ERROR(temBAD_SEND_XRP_LIMIT, "Malformed: Limit quality is not allowed for XRP to XRP."), - MAKE_ERROR(temBAD_SEND_XRP_MAX, "Malformed: Send max is not allowed for XRP to XRP."), - MAKE_ERROR(temBAD_SEND_XRP_NO_DIRECT, "Malformed: No Ripple direct is not allowed for XRP to XRP."), - MAKE_ERROR(temBAD_SEND_XRP_PARTIAL, "Malformed: Partial payment is not allowed for XRP to XRP."), - MAKE_ERROR(temBAD_SEND_XRP_PATHS, "Malformed: Paths are not allowed for XRP to XRP."), - MAKE_ERROR(temBAD_SEQUENCE, "Malformed: Sequence is not in the past."), - MAKE_ERROR(temBAD_SIGNATURE, "Malformed: Bad signature."), - MAKE_ERROR(temBAD_SIGNER, "Malformed: No signer may duplicate account or other signers."), - MAKE_ERROR(temBAD_SRC_ACCOUNT, "Malformed: Bad source account."), - MAKE_ERROR(temBAD_TRANSFER_RATE, "Malformed: Transfer rate must be >= 1.0 and <= 2.0"), - MAKE_ERROR(temBAD_WEIGHT, "Malformed: Weight must be a positive value."), - MAKE_ERROR(temDST_IS_SRC, "Destination may not be source."), - MAKE_ERROR(temDST_NEEDED, "Destination not specified."), - MAKE_ERROR(temINVALID, "The transaction is ill-formed."), - MAKE_ERROR(temINVALID_FLAG, "The transaction has an invalid flag."), - MAKE_ERROR(temREDUNDANT, "Sends same currency to self."), - MAKE_ERROR(temRIPPLE_EMPTY, "PathSet with no paths."), - MAKE_ERROR(temUNCERTAIN, "In process of determining result. Never returned."), - MAKE_ERROR(temUNKNOWN, "The transaction requires logic that is not implemented yet."), - MAKE_ERROR(temDISABLED, "The transaction requires logic that is currently disabled."), - MAKE_ERROR(temBAD_TICK_SIZE, "Malformed: Tick size out of range."), - MAKE_ERROR(temINVALID_ACCOUNT_ID, "Malformed: A field contains an invalid account ID."), - MAKE_ERROR(temCANNOT_PREAUTH_SELF, "Malformed: An account may not preauthorize itself."), - MAKE_ERROR(temINVALID_COUNT, "Malformed: Count field outside valid range."), - MAKE_ERROR(temSEQ_AND_TICKET, "Transaction contains a TicketSequence and a non-zero Sequence."), + MAKE_ERROR(temMALFORMED, "Malformed transaction."), + MAKE_ERROR(temBAD_AMOUNT, "Can only send positive amounts."), + MAKE_ERROR(temBAD_CURRENCY, "Malformed: Bad currency."), + MAKE_ERROR(temBAD_EXPIRATION, "Malformed: Bad expiration."), + MAKE_ERROR(temBAD_FEE, "Invalid fee, negative or not XRP."), + MAKE_ERROR(temBAD_ISSUER, "Malformed: Bad issuer."), + MAKE_ERROR(temBAD_LIMIT, "Limits must be non-negative."), + MAKE_ERROR(temBAD_OFFER, "Malformed: Bad offer."), + MAKE_ERROR(temBAD_PATH, "Malformed: Bad path."), + MAKE_ERROR(temBAD_PATH_LOOP, "Malformed: Loop in path."), + MAKE_ERROR(temBAD_QUORUM, "Malformed: Quorum is unreachable."), + MAKE_ERROR(temBAD_REGKEY, "Malformed: Regular key cannot be same as master key."), + MAKE_ERROR(temBAD_SEND_XRP_LIMIT, "Malformed: Limit quality is not allowed for XRP to XRP."), + MAKE_ERROR(temBAD_SEND_XRP_MAX, "Malformed: Send max is not allowed for XRP to XRP."), + MAKE_ERROR(temBAD_SEND_XRP_NO_DIRECT, "Malformed: No Ripple direct is not allowed for XRP to XRP."), + MAKE_ERROR(temBAD_SEND_XRP_PARTIAL, "Malformed: Partial payment is not allowed for XRP to XRP."), + MAKE_ERROR(temBAD_SEND_XRP_PATHS, "Malformed: Paths are not allowed for XRP to XRP."), + MAKE_ERROR(temBAD_SEQUENCE, "Malformed: Sequence is not in the past."), + MAKE_ERROR(temBAD_SIGNATURE, "Malformed: Bad signature."), + MAKE_ERROR(temBAD_SIGNER, "Malformed: No signer may duplicate account or other signers."), + MAKE_ERROR(temBAD_SRC_ACCOUNT, "Malformed: Bad source account."), + MAKE_ERROR(temBAD_TRANSFER_RATE, "Malformed: Transfer rate must be >= 1.0 and <= 2.0"), + MAKE_ERROR(temBAD_WEIGHT, "Malformed: Weight must be a positive value."), + MAKE_ERROR(temDST_IS_SRC, "Destination may not be source."), + MAKE_ERROR(temDST_NEEDED, "Destination not specified."), + MAKE_ERROR(temINVALID, "The transaction is ill-formed."), + MAKE_ERROR(temINVALID_FLAG, "The transaction has an invalid flag."), + MAKE_ERROR(temREDUNDANT, "The transaction is redundant."), + MAKE_ERROR(temRIPPLE_EMPTY, "PathSet with no paths."), + MAKE_ERROR(temUNCERTAIN, "In process of determining result. Never returned."), + MAKE_ERROR(temUNKNOWN, "The transaction requires logic that is not implemented yet."), + MAKE_ERROR(temDISABLED, "The transaction requires logic that is currently disabled."), + MAKE_ERROR(temBAD_TICK_SIZE, "Malformed: Tick size out of range."), + MAKE_ERROR(temINVALID_ACCOUNT_ID, "Malformed: A field contains an invalid account ID."), + MAKE_ERROR(temCANNOT_PREAUTH_SELF, "Malformed: An account may not preauthorize itself."), + MAKE_ERROR(temINVALID_COUNT, "Malformed: Count field outside valid range."), + MAKE_ERROR(temSEQ_AND_TICKET, "Transaction contains a TicketSequence and a non-zero Sequence."), + MAKE_ERROR(temBAD_NFTOKEN_TRANSFER_FEE, "Malformed: The NFToken transfer fee must be between 1 and 5000, inclusive."), MAKE_ERROR(terRETRY, "Retry transaction."), MAKE_ERROR(terFUNDS_SPENT, "DEPRECATED."), @@ -166,6 +176,7 @@ transResults() MAKE_ERROR(terOWNERS, "Non-zero owner count."), MAKE_ERROR(terQUEUED, "Held until escalated fee drops."), MAKE_ERROR(terPRE_TICKET, "Ticket is not yet in ledger."), + MAKE_ERROR(tesSUCCESS, "The transaction was applied. Only final in a validated ledger."), }; // clang-format on diff --git a/src/ripple/protocol/impl/TxFormats.cpp b/src/ripple/protocol/impl/TxFormats.cpp index ff3f7f507a9..ce0d5db921f 100644 --- a/src/ripple/protocol/impl/TxFormats.cpp +++ b/src/ripple/protocol/impl/TxFormats.cpp @@ -55,6 +55,7 @@ TxFormats::TxFormats() {sfClearFlag, soeOPTIONAL}, {sfTickSize, soeOPTIONAL}, {sfTicketSequence, soeOPTIONAL}, + {sfNFTokenMinter, soeOPTIONAL}, }, commonFields); @@ -271,6 +272,56 @@ TxFormats::TxFormats() {sfTicketSequence, soeOPTIONAL}, }, commonFields); + + add(jss::NFTokenMint, + ttNFTOKEN_MINT, + { + {sfNFTokenTaxon, soeREQUIRED}, + {sfTransferFee, soeOPTIONAL}, + {sfIssuer, soeOPTIONAL}, + {sfURI, soeOPTIONAL}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); + + add(jss::NFTokenBurn, + ttNFTOKEN_BURN, + { + {sfNFTokenID, soeREQUIRED}, + {sfOwner, soeOPTIONAL}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); + + add(jss::NFTokenCreateOffer, + ttNFTOKEN_CREATE_OFFER, + { + {sfNFTokenID, soeREQUIRED}, + {sfAmount, soeREQUIRED}, + {sfDestination, soeOPTIONAL}, + {sfOwner, soeOPTIONAL}, + {sfExpiration, soeOPTIONAL}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); + + add(jss::NFTokenCancelOffer, + ttNFTOKEN_CANCEL_OFFER, + { + {sfNFTokenOffers, soeREQUIRED}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); + + add(jss::NFTokenAcceptOffer, + ttNFTOKEN_ACCEPT_OFFER, + { + {sfNFTokenBuyOffer, soeOPTIONAL}, + {sfNFTokenSellOffer, soeOPTIONAL}, + {sfNFTokenBrokerFee, soeOPTIONAL}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); } TxFormats const& diff --git a/src/ripple/protocol/jss.h b/src/ripple/protocol/jss.h index bd9edd02eff..0dc413e6d20 100644 --- a/src/ripple/protocol/jss.h +++ b/src/ripple/protocol/jss.h @@ -71,6 +71,13 @@ JSS(Invalid); // JSS(LastLedgerSequence); // in: TransactionSign; field JSS(LedgerHashes); // ledger type. JSS(LimitAmount); // field. +JSS(NFTokenBurn); // transaction type. +JSS(NFTokenMint); // transaction type. +JSS(NFTokenOffer); // ledger type. +JSS(NFTokenAcceptOffer); // transaction type. +JSS(NFTokenCancelOffer); // transaction type. +JSS(NFTokenCreateOffer); // transaction type. +JSS(NFTokenPage); // ledger type. JSS(Offer); // ledger type. JSS(OfferCancel); // transaction type. JSS(OfferCreate); // transaction type. @@ -109,6 +116,7 @@ JSS(accountTreeHash); // out: ledger/Ledger.cpp JSS(account_data); // out: AccountInfo JSS(account_hash); // out: LedgerToJson JSS(account_id); // out: WalletPropose +JSS(account_nfts); // out: AccountNFTs JSS(account_objects); // out: AccountObjects JSS(account_root); // in: LedgerEntry JSS(account_sequence_next); // out: SubmitTransaction @@ -207,6 +215,7 @@ JSS(deposit_preauth); // in: AccountObjects, LedgerData JSS(deprecated); // out JSS(descending); // in: AccountTx* JSS(description); // in/out: Reservations +JSS(destination); // in: nft_buy_offers, nft_sell_offers JSS(destination_account); // in: PathRequest, RipplePathFind, account_lines // out: AccountChannels JSS(destination_amount); // in: PathRequest, RipplePathFind @@ -392,6 +401,11 @@ JSS(needed_transaction_hashes); // out: InboundLedger JSS(network_id); // out: NetworkOPs JSS(network_ledger); // out: NetworkOPs JSS(next_refresh_time); // out: ValidatorSite +JSS(nft_id); // in: nft_sell_offers, nft_buy_offers +JSS(nft_offer); // in: LedgerEntry +JSS(nft_offer_index); // out nft_buy_offers, nft_sell_offers +JSS(nft_page); // in: LedgerEntry +JSS(nft_serial); // out: account_nfts JSS(no_ripple); // out: AccountLines JSS(no_ripple_peer); // out: AccountLines JSS(node); // out: LedgerEntry @@ -420,21 +434,22 @@ JSS(open_ledger_fee); // out: TxQ JSS(open_ledger_level); // out: TxQ JSS(owner); // in: LedgerEntry, out: NetworkOPs JSS(owner_funds); // in/out: Ledger, NetworkOPs, AcceptedLedgerTx -JSS(params); // RPC -JSS(parent_close_time); // out: LedgerToJson -JSS(parent_hash); // out: LedgerToJson -JSS(partition); // in: LogLevel -JSS(passphrase); // in: WalletPropose -JSS(password); // in: Subscribe -JSS(paths); // in: RipplePathFind -JSS(paths_canonical); // out: RipplePathFind -JSS(paths_computed); // out: PathRequest, RipplePathFind -JSS(payment_channel); // in: LedgerEntry -JSS(peer); // in: AccountLines -JSS(peer_authorized); // out: AccountLines -JSS(peer_id); // out: RCLCxPeerPos -JSS(peers); // out: InboundLedger, handlers/Peers, Overlay -JSS(peer_disconnects); // Severed peer connection counter. +JSS(page_index); +JSS(params); // RPC +JSS(parent_close_time); // out: LedgerToJson +JSS(parent_hash); // out: LedgerToJson +JSS(partition); // in: LogLevel +JSS(passphrase); // in: WalletPropose +JSS(password); // in: Subscribe +JSS(paths); // in: RipplePathFind +JSS(paths_canonical); // out: RipplePathFind +JSS(paths_computed); // out: PathRequest, RipplePathFind +JSS(payment_channel); // in: LedgerEntry +JSS(peer); // in: AccountLines +JSS(peer_authorized); // out: AccountLines +JSS(peer_id); // out: RCLCxPeerPos +JSS(peers); // out: InboundLedger, handlers/Peers, Overlay +JSS(peer_disconnects); // Severed peer connection counter. JSS(peer_disconnects_resources); // Severed peer connections because of // excess resource consumption. JSS(port); // in: Connect diff --git a/src/ripple/protocol/nftPageMask.h b/src/ripple/protocol/nftPageMask.h new file mode 100644 index 00000000000..a4890b460cd --- /dev/null +++ b/src/ripple/protocol/nftPageMask.h @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2022 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_PROTOCOL_NFT_PAGE_MASK_H_INCLUDED +#define RIPPLE_PROTOCOL_NFT_PAGE_MASK_H_INCLUDED + +#include +#include + +namespace ripple { +namespace nft { + +// NFT directory pages order their contents based only on the low 96 bits of +// the NFToken value. This mask provides easy access to the necessary mask. +uint256 constexpr pageMask(std::string_view( + "0000000000000000000000000000000000000000ffffffffffffffffffffffff")); + +} // namespace nft +} // namespace ripple + +#endif diff --git a/src/ripple/rpc/handlers/AccountObjects.cpp b/src/ripple/rpc/handlers/AccountObjects.cpp index e2a9b86e091..55fe4e4136b 100644 --- a/src/ripple/rpc/handlers/AccountObjects.cpp +++ b/src/ripple/rpc/handlers/AccountObjects.cpp @@ -18,6 +18,7 @@ //============================================================================== #include +#include #include #include #include @@ -46,6 +47,103 @@ namespace ripple { } */ +Json::Value +doAccountNFTs(RPC::JsonContext& context) +{ + auto const& params = context.params; + if (!params.isMember(jss::account)) + return RPC::missing_field_error(jss::account); + + std::shared_ptr ledger; + auto result = RPC::lookupLedger(ledger, context); + if (ledger == nullptr) + return result; + + AccountID accountID; + { + auto const strIdent = params[jss::account].asString(); + if (auto jv = RPC::accountFromString(accountID, strIdent)) + { + for (auto it = jv.begin(); it != jv.end(); ++it) + result[it.memberName()] = *it; + + return result; + } + } + + if (!ledger->exists(keylet::account(accountID))) + return rpcError(rpcACT_NOT_FOUND); + + unsigned int limit; + if (auto err = readLimitField(limit, RPC::Tuning::accountNFTokens, context)) + return *err; + + uint256 marker; + + if (params.isMember(jss::marker)) + { + auto const& m = params[jss::marker]; + if (!m.isString()) + return RPC::expected_field_error(jss::marker, "string"); + + if (!marker.parseHex(m.asString())) + return RPC::invalid_field_error(jss::marker); + } + + auto const first = keylet::nftpage(keylet::nftpage_min(accountID), marker); + auto const last = keylet::nftpage_max(accountID); + + auto cp = ledger->read(Keylet( + ltNFTOKEN_PAGE, + ledger->succ(first.key, last.key.next()).value_or(last.key))); + + std::uint32_t cnt = 0; + auto& nfts = (result[jss::account_nfts] = Json::arrayValue); + + // Continue iteration from the current page: + + while (cp) + { + auto arr = cp->getFieldArray(sfNFTokens); + + for (auto const& o : arr) + { + if (o.getFieldH256(sfNFTokenID) <= marker) + continue; + + { + 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] = + nft::toUInt32(nft::getTaxon(nftokenID)); + obj[jss::nft_serial] = nft::getSerial(nftokenID); + if (std::uint16_t xferFee = {nft::getTransferFee(nftokenID)}) + obj[sfTransferFee.jsonName] = xferFee; + } + + if (++cnt == limit) + { + result[jss::limit] = limit; + result[jss::marker] = to_string(o.getFieldH256(sfNFTokenID)); + return result; + } + } + + if (auto npm = (*cp)[~sfNextPageMin]) + cp = ledger->read(Keylet(ltNFTOKEN_PAGE, *npm)); + else + cp = nullptr; + } + + result[jss::account] = context.app.accountIDCache().toBase58(accountID); + context.loadType = Resource::feeMediumBurdenRPC; + return result; +} + Json::Value doAccountObjects(RPC::JsonContext& context) { diff --git a/src/ripple/rpc/handlers/Handlers.h b/src/ripple/rpc/handlers/Handlers.h index 264d0a3f17d..1bb3be05654 100644 --- a/src/ripple/rpc/handlers/Handlers.h +++ b/src/ripple/rpc/handlers/Handlers.h @@ -33,6 +33,8 @@ doAccountLines(RPC::JsonContext&); Json::Value doAccountChannels(RPC::JsonContext&); Json::Value +doAccountNFTs(RPC::JsonContext&); +Json::Value doAccountObjects(RPC::JsonContext&); Json::Value doAccountOffers(RPC::JsonContext&); @@ -89,6 +91,10 @@ doLogRotate(RPC::JsonContext&); Json::Value doManifest(RPC::JsonContext&); Json::Value +doNFTBuyOffers(RPC::JsonContext&); +Json::Value +doNFTSellOffers(RPC::JsonContext&); +Json::Value doNodeToShard(RPC::JsonContext&); Json::Value doNoRippleCheck(RPC::JsonContext&); diff --git a/src/ripple/rpc/handlers/LedgerEntry.cpp b/src/ripple/rpc/handlers/LedgerEntry.cpp index 12696abb3f5..4b2526698b4 100644 --- a/src/ripple/rpc/handlers/LedgerEntry.cpp +++ b/src/ripple/rpc/handlers/LedgerEntry.cpp @@ -328,9 +328,38 @@ doLedgerEntry(RPC::JsonContext& context) *id, context.params[jss::ticket][jss::ticket_seq].asUInt()); } } + else if (context.params.isMember(jss::nft_page)) + { + expectedType = ltNFTOKEN_PAGE; + + if (context.params[jss::nft_page].isString()) + { + if (!uNodeIndex.parseHex(context.params[jss::nft_page].asString())) + { + uNodeIndex = beast::zero; + jvResult[jss::error] = "malformedRequest"; + } + } + else + { + jvResult[jss::error] = "malformedRequest"; + } + } else { - jvResult[jss::error] = "unknownOption"; + if (context.params.isMember("params") && + context.params["params"].isArray() && + context.params["params"].size() == 1 && + context.params["params"][0u].isString()) + { + if (!uNodeIndex.parseHex(context.params["params"][0u].asString())) + { + uNodeIndex = beast::zero; + jvResult[jss::error] = "malformedRequest"; + } + } + else + jvResult[jss::error] = "unknownOption"; } if (uNodeIndex.isNonZero()) @@ -347,7 +376,7 @@ doLedgerEntry(RPC::JsonContext& context) else if ( (expectedType != ltANY) && (expectedType != sleNode->getType())) { - jvResult[jss::error] = "malformedRequest"; + jvResult[jss::error] = "unexpectedLedgerType"; } else if (bNodeBinary) { diff --git a/src/ripple/rpc/handlers/NFTOffers.cpp b/src/ripple/rpc/handlers/NFTOffers.cpp new file mode 100644 index 00000000000..34bbc8446b9 --- /dev/null +++ b/src/ripple/rpc/handlers/NFTOffers.cpp @@ -0,0 +1,180 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2022 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 +#include +#include +#include +#include +#include +#include + +namespace ripple { + +static void +appendNftOfferJson( + Application const& app, + std::shared_ptr const& offer, + Json::Value& offers) +{ + Json::Value& obj(offers.append(Json::objectValue)); + + obj[jss::nft_offer_index] = to_string(offer->key()); + obj[jss::flags] = (*offer)[sfFlags]; + obj[jss::owner] = + app.accountIDCache().toBase58(offer->getAccountID(sfOwner)); + + if (offer->isFieldPresent(sfDestination)) + obj[jss::destination] = + app.accountIDCache().toBase58(offer->getAccountID(sfDestination)); + + if (offer->isFieldPresent(sfExpiration)) + obj[jss::expiration] = offer->getFieldU32(sfExpiration); + + offer->getFieldAmount(sfAmount).setJson(obj[jss::amount]); +} + +// { +// nft_id: +// ledger_hash : +// ledger_index : +// limit: integer // optional +// marker: opaque // optional, resume previous query +// } +static Json::Value +enumerateNFTOffers( + RPC::JsonContext& context, + uint256 const& nftId, + Keylet const& directory) +{ + unsigned int limit; + if (auto err = readLimitField(limit, RPC::Tuning::nftOffers, context)) + return *err; + + std::shared_ptr ledger; + + if (auto result = RPC::lookupLedger(ledger, context); !ledger) + return result; + + if (!ledger->exists(directory)) + return rpcError(rpcOBJECT_NOT_FOUND); + + Json::Value result; + result[jss::nft_id] = to_string(nftId); + + Json::Value& jsonOffers(result[jss::offers] = Json::arrayValue); + + std::vector> offers; + unsigned int reserve(limit); + uint256 startAfter; + std::uint64_t startHint = 0; + + if (context.params.isMember(jss::marker)) + { + // We have a start point. Use limit - 1 from the result and use the + // very last one for the resume. + Json::Value const& marker(context.params[jss::marker]); + + if (!marker.isString()) + return RPC::expected_field_error(jss::marker, "string"); + + if (!startAfter.parseHex(marker.asString())) + return rpcError(rpcINVALID_PARAMS); + + auto const sle = ledger->read(keylet::nftoffer(startAfter)); + + if (!sle || nftId != sle->getFieldH256(sfNFTokenID)) + return rpcError(rpcINVALID_PARAMS); + + startHint = sle->getFieldU64(sfNFTokenOfferNode); + appendNftOfferJson(context.app, sle, jsonOffers); + offers.reserve(reserve); + } + else + { + // We have no start point, limit should be one higher than requested. + offers.reserve(++reserve); + } + + if (!forEachItemAfter( + *ledger, + directory, + startAfter, + startHint, + reserve, + [&offers](std::shared_ptr const& offer) { + if (offer->getType() == ltNFTOKEN_OFFER) + { + offers.emplace_back(offer); + return true; + } + + return false; + })) + { + return rpcError(rpcINVALID_PARAMS); + } + + if (offers.size() == reserve) + { + result[jss::limit] = limit; + result[jss::marker] = to_string(offers.back()->key()); + offers.pop_back(); + } + + for (auto const& offer : offers) + appendNftOfferJson(context.app, offer, jsonOffers); + + context.loadType = Resource::feeMediumBurdenRPC; + return result; +} + +Json::Value +doNFTSellOffers(RPC::JsonContext& context) +{ + if (!context.params.isMember(jss::nft_id)) + return RPC::missing_field_error(jss::nft_id); + + uint256 nftId; + + if (!nftId.parseHex(context.params[jss::nft_id].asString())) + return RPC::invalid_field_error(jss::nft_id); + + return enumerateNFTOffers(context, nftId, keylet::nft_sells(nftId)); +} + +Json::Value +doNFTBuyOffers(RPC::JsonContext& context) +{ + if (!context.params.isMember(jss::nft_id)) + return RPC::missing_field_error(jss::nft_id); + + uint256 nftId; + + if (!nftId.parseHex(context.params[jss::nft_id].asString())) + return RPC::invalid_field_error(jss::nft_id); + + return enumerateNFTOffers(context, nftId, keylet::nft_buys(nftId)); +} + +} // namespace ripple diff --git a/src/ripple/rpc/impl/GRPCHelpers.cpp b/src/ripple/rpc/impl/GRPCHelpers.cpp index e6a0100e95a..558c9d53566 100644 --- a/src/ripple/rpc/impl/GRPCHelpers.cpp +++ b/src/ripple/rpc/impl/GRPCHelpers.cpp @@ -117,7 +117,7 @@ void populateProtoCurrency( T const& getProto, STObject const& from, - SF_HASH160 const& field) + SF_UINT160 const& field) { if (from.isFieldPresent(field)) { @@ -770,6 +770,14 @@ populateIndexes(T& to, STObject const& from) populateProtoVec256([&to]() { return to.add_indexes(); }, from, sfIndexes); } +template +void +populateNFTokenOffers(T& to, STObject const& from) +{ + populateProtoVec256( + [&to]() { return to.add_nftoken_offers(); }, from, sfNFTokenOffers); +} + template void populateRootIndex(T& to, STObject const& from) @@ -862,6 +870,121 @@ populateReferenceFeeUnits(T& to, STObject const& from) sfReferenceFeeUnits); } +template +void +populatePreviousPageMin(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_previous_page_min(); }, + from, + sfPreviousPageMin); +} + +template +void +populateNextPageMin(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_next_page_min(); }, from, sfNextPageMin); +} + +template +void +populateNFTokenID(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_nftoken_id(); }, from, sfNFTokenID); +} + +template +void +populateURI(T& to, STObject const& from) +{ + populateProtoVLasString([&to]() { return to.mutable_uri(); }, from, sfURI); +} + +template +void +populateBurnedNFTokens(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_burned_nftokens(); }, + from, + sfBurnedNFTokens); +} + +template +void +populateMintedNFTokens(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_minted_nftokens(); }, + from, + sfMintedNFTokens); +} + +template +void +populateNFTokenMinter(T& to, STObject const& from) +{ + populateProtoAccount( + [&to]() { return to.mutable_nftoken_minter(); }, from, sfNFTokenMinter); +} + +template +void +populateNFTokenBrokerFee(T& to, STObject const& from) +{ + populateProtoAmount( + [&to]() { return to.mutable_nftoken_broker_fee(); }, + from, + sfNFTokenBrokerFee); +} + +template +void +populateNFTokenBuyOffer(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_nftoken_buy_offer(); }, + from, + sfNFTokenBuyOffer); +} + +template +void +populateNFTokenSellOffer(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_nftoken_sell_offer(); }, + from, + sfNFTokenSellOffer); +} + +template +void +populateIssuer(T& to, STObject const& from) +{ + populateProtoAccount( + [&to]() { return to.mutable_issuer(); }, from, sfIssuer); +} + +template +void +populateNFTokenTaxon(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_nftoken_taxon(); }, from, sfNFTokenTaxon); +} + +template +void +populateTransferFee(T& to, STObject const& from) +{ + populateProtoPrimitive( + [&to]() { return to.mutable_transfer_fee(); }, from, sfTransferFee); +} + template void populateReserveBase(T& to, STObject const& from) @@ -957,6 +1080,21 @@ populateMajorities(T& to, STObject const& from) sfMajority); } +template +void +populateNFTokens(T& to, STObject const& from) +{ + populateProtoArray( + [&to]() { return to.add_nftokens(); }, + [](auto innerObj, auto innerProto) { + populateNFTokenID(innerProto, innerObj); + populateURI(innerProto, innerObj); + }, + from, + sfNFTokens, + sfNFToken); +} + void convert(org::xrpl::rpc::v1::TransactionResult& to, TER from) { @@ -1003,6 +1141,8 @@ convert(org::xrpl::rpc::v1::AccountSet& to, STObject const& from) populateMessageKey(to, from); + populateNFTokenMinter(to, from); + populateSetFlag(to, from); populateTransferRate(to, from); @@ -1108,6 +1248,56 @@ convert(org::xrpl::rpc::v1::EscrowFinish& to, STObject const& from) populateFulfillment(to, from); } +void +convert(org::xrpl::rpc::v1::NFTokenAcceptOffer& to, STObject const& from) +{ + populateNFTokenBrokerFee(to, from); + + populateNFTokenBuyOffer(to, from); + + populateNFTokenSellOffer(to, from); +} + +void +convert(org::xrpl::rpc::v1::NFTokenBurn& to, STObject const& from) +{ + populateOwner(to, from); + + populateNFTokenID(to, from); +} + +void +convert(org::xrpl::rpc::v1::NFTokenCancelOffer& to, STObject const& from) +{ + populateNFTokenOffers(to, from); +} + +void +convert(org::xrpl::rpc::v1::NFTokenCreateOffer& to, STObject const& from) +{ + populateAmount(to, from); + + populateDestination(to, from); + + populateExpiration(to, from); + + populateOwner(to, from); + + populateNFTokenID(to, from); +} + +void +convert(org::xrpl::rpc::v1::NFTokenMint& to, STObject const& from) +{ + populateIssuer(to, from); + + populateNFTokenTaxon(to, from); + + populateTransferFee(to, from); + + populateURI(to, from); +} + void convert(org::xrpl::rpc::v1::PaymentChannelClaim& to, STObject const& from) { @@ -1265,6 +1455,12 @@ convert(org::xrpl::rpc::v1::AccountRoot& to, STObject const& from) populateTickSize(to, from); populateTransferRate(to, from); + + populateBurnedNFTokens(to, from); + + populateMintedNFTokens(to, from); + + populateNFTokenMinter(to, from); } void @@ -1427,6 +1623,8 @@ convert(org::xrpl::rpc::v1::DirectoryNode& to, STObject const& from) populateTakerGetsCurrency(to, from); populateTakerGetsIssuer(to, from); + + populateNFTokenID(to, from); } void @@ -1517,6 +1715,44 @@ convert(org::xrpl::rpc::v1::TicketObject& to, STObject const& from) populateTicketSequence(to, from); } +void +convert(org::xrpl::rpc::v1::NFTokenOffer& to, STObject const& from) +{ + populateFlags(to, from); + + populateOwner(to, from); + + populateNFTokenID(to, from); + + populateAmount(to, from); + + populateOwnerNode(to, from); + + populateDestination(to, from); + + populateExpiration(to, from); + + populatePreviousTransactionID(to, from); + + populatePreviousTransactionLedgerSequence(to, from); +} + +void +convert(org::xrpl::rpc::v1::NFTokenPage& to, STObject const& from) +{ + populateFlags(to, from); + + populatePreviousPageMin(to, from); + + populateNextPageMin(to, from); + + populateNFTokens(to, from); + + populatePreviousTransactionID(to, from); + + populatePreviousTransactionLedgerSequence(to, from); +} + void setLedgerEntryType( org::xrpl::rpc::v1::AffectedNode& proto, @@ -1580,6 +1816,14 @@ setLedgerEntryType( proto.set_ledger_entry_type( org::xrpl::rpc::v1::LEDGER_ENTRY_TYPE_TICKET); break; + case ltNFTOKEN_OFFER: + proto.set_ledger_entry_type( + org::xrpl::rpc::v1::LEDGER_ENTRY_TYPE_NFTOKEN_OFFER); + break; + case ltNFTOKEN_PAGE: + proto.set_ledger_entry_type( + org::xrpl::rpc::v1::LEDGER_ENTRY_TYPE_NFTOKEN_PAGE); + break; } } @@ -1631,6 +1875,12 @@ convert(T& to, STObject& from, std::uint16_t type) case ltTICKET: RPC::convert(*to.mutable_ticket(), from); break; + case ltNFTOKEN_OFFER: + RPC::convert(*to.mutable_nftoken_offer(), from); + break; + case ltNFTOKEN_PAGE: + RPC::convert(*to.mutable_nftoken_page(), from); + break; } } @@ -1909,6 +2159,21 @@ convert( case TxType::ttTICKET_CREATE: convert(*to.mutable_ticket_create(), fromObj); break; + case TxType::ttNFTOKEN_MINT: + convert(*to.mutable_nftoken_mint(), fromObj); + break; + case TxType::ttNFTOKEN_BURN: + convert(*to.mutable_nftoken_burn(), fromObj); + break; + case TxType::ttNFTOKEN_CREATE_OFFER: + convert(*to.mutable_nftoken_create_offer(), fromObj); + break; + case TxType::ttNFTOKEN_CANCEL_OFFER: + convert(*to.mutable_nftoken_cancel_offer(), fromObj); + break; + case TxType::ttNFTOKEN_ACCEPT_OFFER: + convert(*to.mutable_nftoken_accept_offer(), fromObj); + break; default: break; } diff --git a/src/ripple/rpc/impl/Handler.cpp b/src/ripple/rpc/impl/Handler.cpp index 3613bf723e1..15f2ea8f856 100644 --- a/src/ripple/rpc/impl/Handler.cpp +++ b/src/ripple/rpc/impl/Handler.cpp @@ -67,6 +67,7 @@ Handler const handlerArray[]{ NO_CONDITION}, {"account_lines", byRef(&doAccountLines), Role::USER, NO_CONDITION}, {"account_channels", byRef(&doAccountChannels), Role::USER, NO_CONDITION}, + {"account_nfts", byRef(&doAccountNFTs), Role::USER, NO_CONDITION}, {"account_objects", byRef(&doAccountObjects), Role::USER, NO_CONDITION}, {"account_offers", byRef(&doAccountOffers), Role::USER, NO_CONDITION}, {"account_tx", byRef(&doAccountTxJson), Role::USER, NO_CONDITION}, @@ -111,6 +112,8 @@ Handler const handlerArray[]{ {"log_level", byRef(&doLogLevel), Role::ADMIN, NO_CONDITION}, {"logrotate", byRef(&doLogRotate), Role::ADMIN, NO_CONDITION}, {"manifest", byRef(&doManifest), Role::USER, NO_CONDITION}, + {"nft_buy_offers", byRef(&doNFTBuyOffers), Role::USER, NO_CONDITION}, + {"nft_sell_offers", byRef(&doNFTSellOffers), Role::USER, NO_CONDITION}, {"node_to_shard", byRef(&doNodeToShard), Role::ADMIN, NO_CONDITION}, {"noripple_check", byRef(&doNoRippleCheck), Role::USER, NO_CONDITION}, {"owner_info", byRef(&doOwnerInfo), Role::USER, NEEDS_CURRENT_LEDGER}, diff --git a/src/ripple/rpc/impl/RPCHelpers.cpp b/src/ripple/rpc/impl/RPCHelpers.cpp index c471d2b355a..499f12323f3 100644 --- a/src/ripple/rpc/impl/RPCHelpers.cpp +++ b/src/ripple/rpc/impl/RPCHelpers.cpp @@ -884,7 +884,7 @@ chooseLedgerEntryType(Json::Value const& params) std::pair result{RPC::Status::OK, ltANY}; if (params.isMember(jss::type)) { - static constexpr std::array, 13> + static constexpr std::array, 14> types{ {{jss::account, ltACCOUNT_ROOT}, {jss::amendments, ltAMENDMENTS}, @@ -898,7 +898,8 @@ chooseLedgerEntryType(Json::Value const& params) {jss::payment_channel, ltPAYCHAN}, {jss::signer_list, ltSIGNER_LIST}, {jss::state, ltRIPPLE_STATE}, - {jss::ticket, ltTICKET}}}; + {jss::ticket, ltTICKET}, + {jss::nft_offer, ltNFTOKEN_OFFER}}}; auto const& p = params[jss::type]; if (!p.isString()) diff --git a/src/ripple/rpc/impl/Tuning.h b/src/ripple/rpc/impl/Tuning.h index 233e7379489..4f4a8be1bf7 100644 --- a/src/ripple/rpc/impl/Tuning.h +++ b/src/ripple/rpc/impl/Tuning.h @@ -51,6 +51,12 @@ static LimitRange constexpr bookOffers = {0, 60, 100}; /** Limits for the no_ripple_check command. */ static LimitRange constexpr noRippleCheck = {10, 300, 400}; +/** Limits for the account_nftokens command, in pages. */ +static LimitRange constexpr accountNFTokens = {20, 100, 400}; + +/** Limits for the nft_buy_offers & nft_sell_offers commands. */ +static LimitRange constexpr nftOffers = {50, 250, 500}; + static int constexpr defaultAutoFillFeeMultiplier = 10; static int constexpr defaultAutoFillFeeDivisor = 1; static int constexpr maxPathfindsInProgress = 2; diff --git a/src/ripple/shamap/SHAMap.h b/src/ripple/shamap/SHAMap.h index 1d221179c16..2f0a677f972 100644 --- a/src/ripple/shamap/SHAMap.h +++ b/src/ripple/shamap/SHAMap.h @@ -210,14 +210,21 @@ class SHAMap peekItem(uint256 const& id, SHAMapHash& hash) const; // traverse functions + /** Find the first item after the given item. - // finds the object in the tree with the smallest object id greater than the - // input id + @param id the identifier of the item. + + @note The item does not need to exist. + */ const_iterator upper_bound(uint256 const& id) const; - // finds the object in the tree with the greatest object id smaller than the - // input id + /** Find the object with the greatest object id smaller than the input id. + + @param id the identifier of the item. + + @note The item does not need to exist. + */ const_iterator lower_bound(uint256 const& id) const; diff --git a/src/ripple/shamap/impl/SHAMap.cpp b/src/ripple/shamap/impl/SHAMap.cpp index 27547aaec84..6f6acb9a7e1 100644 --- a/src/ripple/shamap/impl/SHAMap.cpp +++ b/src/ripple/shamap/impl/SHAMap.cpp @@ -608,8 +608,6 @@ SHAMap::peekItem(uint256 const& id, SHAMapHash& hash) const SHAMap::const_iterator SHAMap::upper_bound(uint256 const& id) const { - // Get a const_iterator to the next item in the tree after a given item - // item need not be in tree SharedPtrNodeStack stack; walkTowardsKey(id, &stack); while (!stack.empty()) diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp new file mode 100644 index 00000000000..e40f4c839a7 --- /dev/null +++ b/src/test/app/NFTokenBurn_test.cpp @@ -0,0 +1,605 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 + +namespace ripple { + +class NFTokenBurn_test : public beast::unit_test::suite +{ + // Helper function that returns the owner count of an account root. + static std::uint32_t + ownerCount(test::jtx::Env const& env, test::jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(sfOwnerCount); + return ret; + } + + // Helper function that returns the number of nfts owned by an account. + static std::uint32_t + nftCount(test::jtx::Env& env, test::jtx::Account const& acct) + { + Json::Value params; + params[jss::account] = acct.human(); + params[jss::type] = "state"; + Json::Value nfts = env.rpc("json", "account_nfts", to_string(params)); + return nfts[jss::result][jss::account_nfts].size(); + }; + + void + testBurnRandom(FeatureBitset features) + { + // Exercise a number of conditions with NFT burning. + testcase("Burn random"); + + using namespace test::jtx; + + Env env{*this, features}; + + // Keep information associated with each account together. + struct AcctStat + { + test::jtx::Account const acct; + std::vector nfts; + + AcctStat(char const* name) : acct(name) + { + } + + operator test::jtx::Account() const + { + return acct; + } + }; + AcctStat alice{"alice"}; + AcctStat becky{"becky"}; + AcctStat minter{"minter"}; + + env.fund(XRP(10000), alice, becky, minter); + env.close(); + + // Both alice and minter mint nfts in case that makes any difference. + env(token::setMinter(alice, minter)); + env.close(); + + // Create enough NFTs that alice, becky, and minter can all have + // at least three pages of NFTs. This will cause more activity in + // the page coalescing code. If we make 210 NFTs in total, we can + // have alice and minter each make 105. That will allow us to + // distribute 70 NFTs to our three participants. + // + // Give each NFT a pseudo-randomly chosen fee so the NFTs are + // distributed pseudo-randomly through the pages. This should + // prevent alice's and minter's NFTs from clustering together + // in becky's directory. + // + // Use a default initialized mercenne_twister because we want the + // effect of random numbers, but we want the test to run the same + // way each time. + std::mt19937 engine; + std::uniform_int_distribution feeDist( + decltype(maxTransferFee){}, maxTransferFee); + + alice.nfts.reserve(105); + while (alice.nfts.size() < 105) + { + std::uint16_t const xferFee = feeDist(engine); + alice.nfts.push_back(token::getNextID( + env, alice, 0u, tfTransferable | tfBurnable, xferFee)); + env(token::mint(alice), + txflags(tfTransferable | tfBurnable), + token::xferFee(xferFee)); + env.close(); + } + + minter.nfts.reserve(105); + while (minter.nfts.size() < 105) + { + std::uint16_t const xferFee = feeDist(engine); + minter.nfts.push_back(token::getNextID( + env, alice, 0u, tfTransferable | tfBurnable, xferFee)); + env(token::mint(minter), + txflags(tfTransferable | tfBurnable), + token::xferFee(xferFee), + token::issuer(alice)); + env.close(); + } + + // All of the NFTs are now minted. Transfer 35 each over to becky so + // we end up with 70 NFTs in each account. + becky.nfts.reserve(70); + { + auto aliceIter = alice.nfts.begin(); + auto minterIter = minter.nfts.begin(); + while (becky.nfts.size() < 70) + { + // We do the same work on alice and minter, so make a lambda. + auto xferNFT = [&env, &becky](AcctStat& acct, auto& iter) { + uint256 offerIndex = + keylet::nftoffer(acct.acct, env.seq(acct.acct)).key; + env(token::createOffer(acct, *iter, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(becky, offerIndex)); + env.close(); + becky.nfts.push_back(*iter); + iter = acct.nfts.erase(iter); + iter += 2; + }; + xferNFT(alice, aliceIter); + xferNFT(minter, minterIter); + } + BEAST_EXPECT(aliceIter == alice.nfts.end()); + BEAST_EXPECT(minterIter == minter.nfts.end()); + } + + // Now all three participants have 70 NFTs. + BEAST_EXPECT(nftCount(env, alice.acct) == 70); + BEAST_EXPECT(nftCount(env, becky.acct) == 70); + BEAST_EXPECT(nftCount(env, minter.acct) == 70); + + // Next we'll create offers for all of those NFTs. This calls for + // another lambda. + auto addOffers = + [&env](AcctStat& owner, AcctStat& other1, AcctStat& other2) { + for (uint256 nft : owner.nfts) + { + // Create sell offers for owner. + env(token::createOffer(owner, nft, drops(1)), + txflags(tfSellNFToken), + token::destination(other1)); + env(token::createOffer(owner, nft, drops(1)), + txflags(tfSellNFToken), + token::destination(other2)); + env.close(); + + // Create buy offers for other1 and other2. + env(token::createOffer(other1, nft, drops(1)), + token::owner(owner)); + env(token::createOffer(other2, nft, drops(1)), + token::owner(owner)); + env.close(); + + env(token::createOffer(other2, nft, drops(2)), + token::owner(owner)); + env(token::createOffer(other1, nft, drops(2)), + token::owner(owner)); + env.close(); + } + }; + addOffers(alice, becky, minter); + addOffers(becky, minter, alice); + addOffers(minter, alice, becky); + BEAST_EXPECT(ownerCount(env, alice) == 424); + BEAST_EXPECT(ownerCount(env, becky) == 424); + BEAST_EXPECT(ownerCount(env, minter) == 424); + + // Now each of the 270 NFTs has six offers associated with it. + // Randomly select an NFT out of the pile and burn it. Continue + // the process until all NFTs are burned. + AcctStat* const stats[3] = {&alice, &becky, &minter}; + std::uniform_int_distribution acctDist(0, 2); + std::uniform_int_distribution mintDist(0, 1); + + while (stats[0]->nfts.size() > 0 || stats[1]->nfts.size() > 0 || + stats[2]->nfts.size() > 0) + { + // Pick an account to burn an nft. If there are no nfts left + // pick again. + AcctStat& owner = *(stats[acctDist(engine)]); + if (owner.nfts.empty()) + continue; + + // Pick one of the nfts. + std::uniform_int_distribution nftDist( + 0lu, owner.nfts.size() - 1); + auto nftIter = owner.nfts.begin() + nftDist(engine); + uint256 const nft = *nftIter; + owner.nfts.erase(nftIter); + + // Decide which of the accounts should burn the nft. If the + // owner is becky then any of the three accounts can burn. + // Otherwise either alice or minter can burn. + AcctStat& burner = owner.acct == becky.acct + ? *(stats[acctDist(engine)]) + : mintDist(engine) ? alice : minter; + + if (owner.acct == burner.acct) + env(token::burn(burner, nft)); + else + env(token::burn(burner, nft), token::owner(owner)); + env.close(); + + // Every time we burn an nft, the number of nfts they hold should + // match the number of nfts we think they hold. + BEAST_EXPECT(nftCount(env, alice.acct) == alice.nfts.size()); + BEAST_EXPECT(nftCount(env, becky.acct) == becky.nfts.size()); + BEAST_EXPECT(nftCount(env, minter.acct) == minter.nfts.size()); + } + BEAST_EXPECT(nftCount(env, alice.acct) == 0); + BEAST_EXPECT(nftCount(env, becky.acct) == 0); + BEAST_EXPECT(nftCount(env, minter.acct) == 0); + + // When all nfts are burned none of the accounts should have + // an ownerCount. + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + } + + void + testBurnSequential(FeatureBitset features) + { + // The earlier burn test randomizes which nft is burned. There are + // a couple of directory merging scenarios that can only be tested by + // inserting and deleting in an ordered fashion. We do that testing + // now. + testcase("Burn sequential"); + + using namespace test::jtx; + + Account const alice{"alice"}; + + Env env{*this, features}; + env.fund(XRP(1000), alice); + + // printNFTPages is a lambda that may be used for debugging. + // + // It uses the ledger RPC command to show the NFT pages in the ledger. + // This parameter controls how noisy the output is. + enum Volume : bool { + quiet = false, + noisy = true, + }; + + [[maybe_unused]] auto printNFTPages = [&env](Volume vol) { + Json::Value jvParams; + jvParams[jss::ledger_index] = "current"; + jvParams[jss::binary] = false; + { + Json::Value jrr = env.rpc( + "json", + "ledger_data", + boost::lexical_cast(jvParams)); + + // Iterate the state and print all NFTokenPages. + if (!jrr.isMember(jss::result) || + !jrr[jss::result].isMember(jss::state)) + { + std::cout << "No ledger state found!" << std::endl; + return; + } + Json::Value& state = jrr[jss::result][jss::state]; + if (!state.isArray()) + { + std::cout << "Ledger state is not array!" << std::endl; + return; + } + for (Json::UInt i = 0; i < state.size(); ++i) + { + if (state[i].isMember(sfNFTokens.jsonName) && + state[i][sfNFTokens.jsonName].isArray()) + { + std::uint32_t tokenCount = + state[i][sfNFTokens.jsonName].size(); + std::cout << tokenCount << " NFTokens in page " + << state[i][jss::index].asString() + << std::endl; + + if (vol == noisy) + { + std::cout << state[i].toStyledString() << std::endl; + } + else + { + if (tokenCount > 0) + std::cout << "first: " + << state[i][sfNFTokens.jsonName][0u] + .toStyledString() + << std::endl; + if (tokenCount > 1) + std::cout << "last: " + << state[i][sfNFTokens.jsonName] + [tokenCount - 1] + .toStyledString() + << std::endl; + } + } + } + } + }; + + // A lambda that generates 96 nfts packed into three pages of 32 each. + auto genPackedTokens = [this, &env, &alice]( + std::vector& nfts) { + nfts.clear(); + nfts.reserve(96); + + // We want to create fully packed NFT pages. This is a little + // tricky since the system currently in place is inclined to + // assign consecutive tokens to only 16 entries per page. + // + // By manipulating the internal form of the taxon we can force + // creation of NFT pages that are completely full. This lambda + // tells us the taxon value we should pass in in order for the + // internal representation to match the passed in value. + auto internalTaxon = [&env]( + Account const& acct, + std::uint32_t taxon) -> std::uint32_t { + std::uint32_t const tokenSeq = { + env.le(acct)->at(~sfMintedNFTokens).value_or(0)}; + return toUInt32( + nft::cipheredTaxon(tokenSeq, nft::toTaxon(taxon))); + }; + + for (std::uint32_t i = 0; i < 96; ++i) + { + // In order to fill the pages we use the taxon to break them + // into groups of 16 entries. By having the internal + // representation of the taxon go... + // 0, 3, 2, 5, 4, 7... + // in sets of 16 NFTs we can get each page to be fully + // populated. + std::uint32_t const intTaxon = (i / 16) + (i & 0b10000 ? 2 : 0); + uint32_t const extTaxon = internalTaxon(alice, intTaxon); + nfts.push_back(token::getNextID(env, alice, extTaxon)); + env(token::mint(alice, extTaxon)); + env.close(); + } + + // Sort the NFTs so they are listed in storage order, not + // creation order. + std::sort(nfts.begin(), nfts.end()); + + // Verify that the ledger does indeed contain exactly three pages + // of NFTs with 32 entries in each page. + Json::Value jvParams; + jvParams[jss::ledger_index] = "current"; + jvParams[jss::binary] = false; + { + Json::Value jrr = env.rpc( + "json", + "ledger_data", + boost::lexical_cast(jvParams)); + + Json::Value& state = jrr[jss::result][jss::state]; + + int pageCount = 0; + for (Json::UInt i = 0; i < state.size(); ++i) + { + if (state[i].isMember(sfNFTokens.jsonName) && + state[i][sfNFTokens.jsonName].isArray()) + { + BEAST_EXPECT( + state[i][sfNFTokens.jsonName].size() == 32); + ++pageCount; + } + } + // If this check fails then the internal NFT directory logic + // has changed. + BEAST_EXPECT(pageCount == 3); + } + }; + + // Generate three packed pages. Then burn the tokens in order from + // first to last. This exercises specific cases where coalescing + // pages is not possible. + std::vector nfts; + genPackedTokens(nfts); + BEAST_EXPECT(nftCount(env, alice) == 96); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + for (uint256 const& nft : nfts) + { + env(token::burn(alice, {nft})); + env.close(); + } + BEAST_EXPECT(nftCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + // A lambda verifies that the ledger no longer contains any NFT pages. + auto checkNoTokenPages = [this, &env]() { + Json::Value jvParams; + jvParams[jss::ledger_index] = "current"; + jvParams[jss::binary] = false; + { + Json::Value jrr = env.rpc( + "json", + "ledger_data", + boost::lexical_cast(jvParams)); + + Json::Value& state = jrr[jss::result][jss::state]; + + for (Json::UInt i = 0; i < state.size(); ++i) + { + BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName)); + } + } + }; + checkNoTokenPages(); + + // Generate three packed pages. Then burn the tokens in order from + // last to first. This exercises different specific cases where + // coalescing pages is not possible. + genPackedTokens(nfts); + BEAST_EXPECT(nftCount(env, alice) == 96); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + std::reverse(nfts.begin(), nfts.end()); + for (uint256 const& nft : nfts) + { + env(token::burn(alice, {nft})); + env.close(); + } + BEAST_EXPECT(nftCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, alice) == 0); + checkNoTokenPages(); + + // Generate three packed pages. Then burn all tokens in the middle + // page. This exercises the case where a page is removed between + // two fully populated pages. + genPackedTokens(nfts); + BEAST_EXPECT(nftCount(env, alice) == 96); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + for (std::size_t i = 32; i < 64; ++i) + { + env(token::burn(alice, nfts[i])); + env.close(); + } + nfts.erase(nfts.begin() + 32, nfts.begin() + 64); + BEAST_EXPECT(nftCount(env, alice) == 64); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + // Burn the remaining nfts. + for (uint256 const& nft : nfts) + { + env(token::burn(alice, {nft})); + env.close(); + } + BEAST_EXPECT(nftCount(env, alice) == 0); + checkNoTokenPages(); + } + + void + testBurnTooManyOffers(FeatureBitset features) + { + // Look at the case where too many offers prevents burning a token. + testcase("Burn too many offers"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice("alice"); + Account const becky("becky"); + env.fund(XRP(1000), alice, becky); + env.close(); + + // We structure the test to try and maximize the metadata produced. + // This verifies that we don't create too much metadata during a + // maximal burn operation. + // + // 1. alice mints an nft with a full-sized URI. + // 2. We create 1000 new accounts, each of which creates an offer for + // alice's nft. + // 3. becky creates one more offer for alice's NFT + // 4. Attempt to burn the nft which fails because there are too + // many offers. + // 5. Cancel becky's offer and the nft should become burnable. + uint256 const nftokenID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0), + token::uri(std::string(maxTokenURILength, 'u')), + txflags(tfTransferable)); + env.close(); + + std::vector offerIndexes; + offerIndexes.reserve(maxTokenOfferCancelCount); + for (uint32_t i = 0; i < maxTokenOfferCancelCount; ++i) + { + Account const acct(std::string("acct") + std::to_string(i)); + env.fund(XRP(1000), acct); + env.close(); + + offerIndexes.push_back(keylet::nftoffer(acct, env.seq(acct)).key); + env(token::createOffer(acct, nftokenID, drops(1)), + token::owner(alice)); + env.close(); + } + + // Verify all offers are present in the ledger. + for (uint256 const& offerIndex : offerIndexes) + { + BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex))); + } + + // Create one too many offers. + uint256 const beckyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftokenID, drops(1)), + token::owner(alice)); + + // Attempt to burn the nft which should fail. + env(token::burn(alice, nftokenID), ter(tefTOO_BIG)); + + // Close enough ledgers that the burn transaction is no longer retried. + for (int i = 0; i < 10; ++i) + env.close(); + + // Cancel becky's offer, but alice adds a sell offer. The token + // should still not be burnable. + env(token::cancelOffer(becky, {beckyOfferIndex})); + env.close(); + + uint256 const aliceOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftokenID, drops(1)), + txflags(tfSellNFToken)); + env.close(); + + env(token::burn(alice, nftokenID), ter(tefTOO_BIG)); + env.close(); + + // Cancel alice's sell offer. Now the token should be burnable. + env(token::cancelOffer(alice, {aliceOfferIndex})); + env.close(); + + env(token::burn(alice, nftokenID)); + env.close(); + + // Burning the token should remove all the offers from the ledger. + for (uint256 const& offerIndex : offerIndexes) + { + BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndex))); + } + + // Both alice and becky should have ownerCounts of zero. + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 0); + } + + void + testWithFeats(FeatureBitset features) + { + testBurnRandom(features); + testBurnSequential(features); + testBurnTooManyOffers(features); + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = supported_amendments(); + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(NFTokenBurn, tx, ripple, 3); + +} // namespace ripple diff --git a/src/test/app/NFTokenDir_test.cpp b/src/test/app/NFTokenDir_test.cpp new file mode 100644 index 00000000000..c19a8d0790a --- /dev/null +++ b/src/test/app/NFTokenDir_test.cpp @@ -0,0 +1,468 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2022 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 + +namespace ripple { + +class NFTokenDir_test : public beast::unit_test::suite +{ + // printNFTPages is a helper function that may be used for debugging. + // + // It uses the ledger RPC command to show the NFT pages in the ledger. + // This parameter controls how noisy the output is. + enum Volume : bool { + quiet = false, + noisy = true, + }; + + void + printNFTPages(test::jtx::Env& env, Volume vol) + { + Json::Value jvParams; + jvParams[jss::ledger_index] = "current"; + jvParams[jss::binary] = false; + { + Json::Value jrr = env.rpc( + "json", + "ledger_data", + boost::lexical_cast(jvParams)); + + // Iterate the state and print all NFTokenPages. + if (!jrr.isMember(jss::result) || + !jrr[jss::result].isMember(jss::state)) + { + std::cout << "No ledger state found!" << std::endl; + return; + } + Json::Value& state = jrr[jss::result][jss::state]; + if (!state.isArray()) + { + std::cout << "Ledger state is not array!" << std::endl; + return; + } + for (Json::UInt i = 0; i < state.size(); ++i) + { + if (state[i].isMember(sfNFTokens.jsonName) && + state[i][sfNFTokens.jsonName].isArray()) + { + std::uint32_t tokenCount = + state[i][sfNFTokens.jsonName].size(); + std::cout << tokenCount << " NFtokens in page " + << state[i][jss::index].asString() << std::endl; + + if (vol == noisy) + { + std::cout << state[i].toStyledString() << std::endl; + } + else + { + if (tokenCount > 0) + std::cout << "first: " + << state[i][sfNFTokens.jsonName][0u] + .toStyledString() + << std::endl; + if (tokenCount > 1) + std::cout + << "last: " + << state[i][sfNFTokens.jsonName][tokenCount - 1] + .toStyledString() + << std::endl; + } + } + } + } + } + + void + testLopsidedSplits(FeatureBitset features) + { + // All NFT IDs with the same low 96 bits must stay on the same NFT page. + testcase("Lopsided splits"); + + using namespace test::jtx; + + // When a single NFT page exceeds 32 entries, the code is inclined + // to split that page into two equal pieces. That's fine, but + // the code also needs to keep NFTs with identical low 96-bits on + // the same page. + // + // Here we synthesize cases where there are several NFTs with + // identical 96-low-bits in the middle of a page. When that page + // is split because it overflows, we need to see that the NFTs + // with identical 96-low-bits are all kept on the same page. + + // Lambda that exercises the lopsided splits. + auto exerciseLopsided = + [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 offers. + for (uint256 const& offer : offers) + { + env(token::acceptSellOffer(buyer, offer)); + 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 cause a lopsided split where the new NFT is added + // to the upper page. + static std::initializer_list const + splitAndAddToHi{ + "sp6JS7f14BuwFY8Mw5p3b8jjQBBTK", // 0. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6F7X3EiGKazu", // 1. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6FxjntJJfKXq", // 2. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6eSF1ydEozJg", // 3. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6koPB91um2ej", // 4. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6m6D64iwquSe", // 5. 0x1d2932ea + + "sp6JS7f14BuwFY8Mw5rC43sN4adC2", // 6. 0x208dbc24 + "sp6JS7f14BuwFY8Mw65L9DDQqgebz", // 7. 0x208dbc24 + "sp6JS7f14BuwFY8Mw65nKvU8pPQNn", // 8. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6bxZLyTrdipw", // 9. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6d5abucntSoX", // 10. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6qXK5awrRRP8", // 11. 0x208dbc24 + + // These eight need to be kept together by the implementation. + "sp6JS7f14BuwFY8Mw66EBtMxoMcCa", // 12. 0x309b67ed + "sp6JS7f14BuwFY8Mw66dGfE9jVfGv", // 13. 0x309b67ed + "sp6JS7f14BuwFY8Mw6APdZa7PH566", // 14. 0x309b67ed + "sp6JS7f14BuwFY8Mw6C3QX5CZyET5", // 15. 0x309b67ed + "sp6JS7f14BuwFY8Mw6CSysFf8GvaR", // 16. 0x309b67ed + "sp6JS7f14BuwFY8Mw6c7QSDmoAeRV", // 17. 0x309b67ed + "sp6JS7f14BuwFY8Mw6mvonveaZhW7", // 18. 0x309b67ed + "sp6JS7f14BuwFY8Mw6vtHHG7dYcXi", // 19. 0x309b67ed + + "sp6JS7f14BuwFY8Mw66yppUNxESaw", // 20. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6ATYQvobXiDT", // 21. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6bis8D1Wa9Uy", // 22. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6cTiGCWA8Wfa", // 23. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6eAy2fpXmyYf", // 24. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6icn58TRs8YG", // 25. 0x40d4b96f + + "sp6JS7f14BuwFY8Mw68tj2eQEWoJt", // 26. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6AjnAinNnMHT", // 27. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6CKDUwB4LrhL", // 28. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6d2yPszEFA6J", // 29. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6jcBQBH3PfnB", // 30. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6qxx19KSnN1w", // 31. 0x503b6ba9 + + // Adding this NFT splits the page. It is added to the upper + // page. + "sp6JS7f14BuwFY8Mw6ut1hFrqWoY5", // 32. 0x503b6ba9 + }; + + // These seeds cause a lopsided split where the new NFT is added + // to the lower page. + static std::initializer_list const + splitAndAddToLo{ + "sp6JS7f14BuwFY8Mw5p3b8jjQBBTK", // 0. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6F7X3EiGKazu", // 1. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6FxjntJJfKXq", // 2. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6eSF1ydEozJg", // 3. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6koPB91um2ej", // 4. 0x1d2932ea + "sp6JS7f14BuwFY8Mw6m6D64iwquSe", // 5. 0x1d2932ea + + "sp6JS7f14BuwFY8Mw5rC43sN4adC2", // 6. 0x208dbc24 + "sp6JS7f14BuwFY8Mw65L9DDQqgebz", // 7. 0x208dbc24 + "sp6JS7f14BuwFY8Mw65nKvU8pPQNn", // 8. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6bxZLyTrdipw", // 9. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6d5abucntSoX", // 10. 0x208dbc24 + "sp6JS7f14BuwFY8Mw6qXK5awrRRP8", // 11. 0x208dbc24 + + // These eight need to be kept together by the implementation. + "sp6JS7f14BuwFY8Mw66EBtMxoMcCa", // 12. 0x309b67ed + "sp6JS7f14BuwFY8Mw66dGfE9jVfGv", // 13. 0x309b67ed + "sp6JS7f14BuwFY8Mw6APdZa7PH566", // 14. 0x309b67ed + "sp6JS7f14BuwFY8Mw6C3QX5CZyET5", // 15. 0x309b67ed + "sp6JS7f14BuwFY8Mw6CSysFf8GvaR", // 16. 0x309b67ed + "sp6JS7f14BuwFY8Mw6c7QSDmoAeRV", // 17. 0x309b67ed + "sp6JS7f14BuwFY8Mw6mvonveaZhW7", // 18. 0x309b67ed + "sp6JS7f14BuwFY8Mw6vtHHG7dYcXi", // 19. 0x309b67ed + + "sp6JS7f14BuwFY8Mw66yppUNxESaw", // 20. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6ATYQvobXiDT", // 21. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6bis8D1Wa9Uy", // 22. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6cTiGCWA8Wfa", // 23. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6eAy2fpXmyYf", // 24. 0x40d4b96f + "sp6JS7f14BuwFY8Mw6icn58TRs8YG", // 25. 0x40d4b96f + + "sp6JS7f14BuwFY8Mw68tj2eQEWoJt", // 26. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6AjnAinNnMHT", // 27. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6CKDUwB4LrhL", // 28. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6d2yPszEFA6J", // 29. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6jcBQBH3PfnB", // 30. 0x503b6ba9 + "sp6JS7f14BuwFY8Mw6qxx19KSnN1w", // 31. 0x503b6ba9 + + // Adding this NFT splits the page. It is added to the lower + // page. + "sp6JS7f14BuwFY8Mw6xCigaMwC6Dp", // 32. 0x309b67ed + }; + + // FUTURE TEST + // These seeds fill the last 17 entries of the initial page with + // equivalent NFTs. The split should keep these together. + + // FUTURE TEST + // These seeds fill the first entries of the initial page with + // equivalent NFTs. The split should keep these together. + + // Run the test cases. + exerciseLopsided(splitAndAddToHi); + exerciseLopsided(splitAndAddToLo); + } + + void + testTooManyEquivalent(FeatureBitset features) + { + // Exercise the case where 33 NFTs with identical sort + // characteristics are owned by the same account. + testcase("NFToken too many same"); + + 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. + // + // 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", + }; + + // 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(); + + // Verify that the low 96 bits of all generated NFTs is identical. + uint256 const expectLowBits = nftIDs.front() & nft::pageMask; + for (uint256 const& nftID : nftIDs) + { + BEAST_EXPECT(expectLowBits == (nftID & nft::pageMask)); + } + + // buyer accepts all of the offers. + 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. + // 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()); + } + + void + testWithFeats(FeatureBitset features) + { + testLopsidedSplits(features); + testTooManyEquivalent(features); + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = supported_amendments(); + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(NFTokenDir, tx, ripple, 1); + +} // namespace ripple diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp new file mode 100644 index 00000000000..40dfe2fe35c --- /dev/null +++ b/src/test/app/NFToken_test.cpp @@ -0,0 +1,4290 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 + +#include + +namespace ripple { + +class NFToken_test : public beast::unit_test::suite +{ + // Helper function that returns the owner count of an account root. + static std::uint32_t + ownerCount(test::jtx::Env const& env, test::jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(sfOwnerCount); + return ret; + } + + // Helper function that returns the number of NFTs minted by an issuer. + static std::uint32_t + mintedCount(test::jtx::Env const& env, test::jtx::Account const& issuer) + { + std::uint32_t ret{0}; + if (auto const sleIssuer = env.le(issuer)) + ret = sleIssuer->at(~sfMintedNFTokens).value_or(0); + return ret; + } + + // Helper function that returns the number of an issuer's burned NFTs. + static std::uint32_t + burnedCount(test::jtx::Env const& env, test::jtx::Account const& issuer) + { + std::uint32_t ret{0}; + if (auto const sleIssuer = env.le(issuer)) + ret = sleIssuer->at(~sfBurnedNFTokens).value_or(0); + return ret; + } + + // Helper function that returns the number of nfts owned by an account. + static std::uint32_t + nftCount(test::jtx::Env& env, test::jtx::Account const& acct) + { + Json::Value params; + params[jss::account] = acct.human(); + params[jss::type] = "state"; + Json::Value nfts = env.rpc("json", "account_nfts", to_string(params)); + return nfts[jss::result][jss::account_nfts].size(); + }; + + // Helper function that returns the number of tickets held by an account. + static std::uint32_t + ticketCount(test::jtx::Env const& env, test::jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(~sfTicketCount).value_or(0); + return ret; + } + + // Helper function returns the close time of the parent ledger. + std::uint32_t + lastClose(test::jtx::Env& env) + { + return env.current()->info().parentCloseTime.time_since_epoch().count(); + } + + void + testEnabled(FeatureBitset features) + { + testcase("Enabled"); + + using namespace test::jtx; + { + // If the NFT amendment is not enabled, you should not be able + // to create or burn NFTs. + Env env{*this, features - featureNonFungibleTokensV1}; + Account const& master = env.master; + + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + uint256 const nftId{token::getNextID(env, master, 0u)}; + env(token::mint(master, 0u), ter(temDISABLED)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + env(token::burn(master, nftId), ter(temDISABLED)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + uint256 const offerIndex = + keylet::nftoffer(master, env.seq(master)).key; + env(token::createOffer(master, nftId, XRP(10)), ter(temDISABLED)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + env(token::cancelOffer(master, {offerIndex}), ter(temDISABLED)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + env(token::acceptBuyOffer(master, offerIndex), ter(temDISABLED)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + } + { + // If the NFT amendment is enabled all NFT-related + // facilities should be available. + Env env{*this, features}; + Account const& master = env.master; + + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 0); + BEAST_EXPECT(burnedCount(env, master) == 0); + + uint256 const nftId0{token::getNextID(env, env.master, 0u)}; + env(token::mint(env.master, 0u)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 1); + BEAST_EXPECT(mintedCount(env, master) == 1); + BEAST_EXPECT(burnedCount(env, master) == 0); + + env(token::burn(env.master, nftId0)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 1); + BEAST_EXPECT(burnedCount(env, master) == 1); + + uint256 const nftId1{ + token::getNextID(env, env.master, 0u, tfTransferable)}; + env(token::mint(env.master, 0u), txflags(tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, master) == 1); + BEAST_EXPECT(mintedCount(env, master) == 2); + BEAST_EXPECT(burnedCount(env, master) == 1); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + uint256 const aliceOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId1, XRP(1000)), + token::owner(master)); + env.close(); + + BEAST_EXPECT(ownerCount(env, master) == 1); + BEAST_EXPECT(mintedCount(env, master) == 2); + BEAST_EXPECT(burnedCount(env, master) == 1); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(mintedCount(env, alice) == 0); + BEAST_EXPECT(burnedCount(env, alice) == 0); + + env(token::acceptBuyOffer(master, aliceOfferIndex)); + env.close(); + + BEAST_EXPECT(ownerCount(env, master) == 0); + BEAST_EXPECT(mintedCount(env, master) == 2); + BEAST_EXPECT(burnedCount(env, master) == 1); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(mintedCount(env, alice) == 0); + BEAST_EXPECT(burnedCount(env, alice) == 0); + } + } + + void + testMintReserve(FeatureBitset features) + { + // Verify that the reserve behaves as expected for minting. + testcase("Mint reserve"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const minter{"minter"}; + + // Fund alice and minter enough to exist, but not enough to meet + // the reserve for creating their first NFT. Account reserve for unit + // tests is 200 XRP, not 20. + env.fund(XRP(200), alice, minter); + env.close(); + BEAST_EXPECT(env.balance(alice) == XRP(200)); + BEAST_EXPECT(env.balance(minter) == XRP(200)); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + + // alice does not have enough XRP to cover the reserve for an NFT page. + env(token::mint(alice, 0u), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(mintedCount(env, alice) == 0); + BEAST_EXPECT(burnedCount(env, alice) == 0); + + // Pay alice almost enough to make the reserve for an NFT page. + env(pay(env.master, alice, XRP(50) + drops(9))); + env.close(); + + // A lambda that checks alice's ownerCount, mintedCount, and + // burnedCount all in one fell swoop. + auto checkAliceOwnerMintedBurned = [&env, this, &alice]( + std::uint32_t owners, + std::uint32_t minted, + std::uint32_t burned, + int line) { + auto oneCheck = + [line, this]( + char const* type, std::uint32_t found, std::uint32_t exp) { + if (found == exp) + pass(); + else + { + std::stringstream ss; + ss << "Wrong " << type << " count. Found: " << found + << "; Expected: " << exp; + fail(ss.str(), __FILE__, line); + } + }; + oneCheck("owner", ownerCount(env, alice), owners); + oneCheck("minted", mintedCount(env, alice), minted); + oneCheck("burned", burnedCount(env, alice), burned); + }; + + // alice still does not have enough XRP for the reserve of an NFT page. + env(token::mint(alice, 0u), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + checkAliceOwnerMintedBurned(0, 0, 0, __LINE__); + + // Pay alice enough to make the reserve for an NFT page. + env(pay(env.master, alice, drops(11))); + env.close(); + + // Now alice can mint an NFT. + env(token::mint(alice)); + env.close(); + checkAliceOwnerMintedBurned(1, 1, 0, __LINE__); + + // Alice should be able to mint an additional 31 NFTs without + // any additional reserve requirements. + for (int i = 1; i < 32; ++i) + { + env(token::mint(alice)); + checkAliceOwnerMintedBurned(1, i + 1, 0, __LINE__); + } + + // That NFT page is full. Creating an additional NFT page requires + // additional reserve. + env(token::mint(alice), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + checkAliceOwnerMintedBurned(1, 32, 0, __LINE__); + + // Pay alice almost enough to make the reserve for an NFT page. + env(pay(env.master, alice, XRP(50) + drops(329))); + env.close(); + + // alice still does not have enough XRP for the reserve of an NFT page. + env(token::mint(alice), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + checkAliceOwnerMintedBurned(1, 32, 0, __LINE__); + + // Pay alice enough to make the reserve for an NFT page. + env(pay(env.master, alice, drops(11))); + env.close(); + + // Now alice can mint an NFT. + env(token::mint(alice)); + env.close(); + checkAliceOwnerMintedBurned(2, 33, 0, __LINE__); + + // alice burns the NFTs she created: check that pages consolidate + std::uint32_t seq = 0; + + while (seq < 33) + { + env(token::burn(alice, token::getID(alice, 0, seq++))); + env.close(); + checkAliceOwnerMintedBurned((33 - seq) ? 1 : 0, 33, seq, __LINE__); + } + + // alice burns a non-existent NFT. + env(token::burn(alice, token::getID(alice, 197, 5)), ter(tecNO_ENTRY)); + env.close(); + checkAliceOwnerMintedBurned(0, 33, 33, __LINE__); + + // That was fun! Now let's see what happens when we let someone else + // mint NFTs on alice's behalf. alice gives permission to minter. + env(token::setMinter(alice, minter)); + env.close(); + BEAST_EXPECT( + env.le(alice)->getAccountID(sfNFTokenMinter) == minter.id()); + + // A lambda that checks minter's and alice's ownerCount, + // mintedCount, and burnedCount all in one fell swoop. + auto checkMintersOwnerMintedBurned = [&env, this, &alice, &minter]( + std::uint32_t aliceOwners, + std::uint32_t aliceMinted, + std::uint32_t aliceBurned, + std::uint32_t minterOwners, + std::uint32_t minterMinted, + std::uint32_t minterBurned, + int line) { + auto oneCheck = [this]( + char const* type, + std::uint32_t found, + std::uint32_t exp, + int line) { + if (found == exp) + pass(); + else + { + std::stringstream ss; + ss << "Wrong " << type << " count. Found: " << found + << "; Expected: " << exp; + fail(ss.str(), __FILE__, line); + } + }; + oneCheck("alice owner", ownerCount(env, alice), aliceOwners, line); + oneCheck( + "alice minted", mintedCount(env, alice), aliceMinted, line); + oneCheck( + "alice burned", burnedCount(env, alice), aliceBurned, line); + oneCheck( + "minter owner", ownerCount(env, minter), minterOwners, line); + oneCheck( + "minter minted", mintedCount(env, minter), minterMinted, line); + oneCheck( + "minter burned", burnedCount(env, minter), minterBurned, line); + }; + + std::uint32_t nftSeq = 33; + + // Pay minter almost enough to make the reserve for an NFT page. + env(pay(env.master, minter, XRP(50) - drops(1))); + env.close(); + checkMintersOwnerMintedBurned(0, 33, nftSeq, 0, 0, 0, __LINE__); + + // minter still does not have enough XRP for the reserve of an NFT page. + // Just for grins (and code coverage), minter mints NFTs that include + // a URI. + env(token::mint(minter), + token::issuer(alice), + token::uri("uri"), + ter(tecINSUFFICIENT_RESERVE)); + env.close(); + checkMintersOwnerMintedBurned(0, 33, nftSeq, 0, 0, 0, __LINE__); + + // Pay minter enough to make the reserve for an NFT page. + env(pay(env.master, minter, drops(11))); + env.close(); + + // Now minter can mint an NFT for alice. + env(token::mint(minter), token::issuer(alice), token::uri("uri")); + env.close(); + checkMintersOwnerMintedBurned(0, 34, nftSeq, 1, 0, 0, __LINE__); + + // Minter should be able to mint an additional 31 NFTs for alice + // without any additional reserve requirements. + for (int i = 1; i < 32; ++i) + { + env(token::mint(minter), token::issuer(alice), token::uri("uri")); + checkMintersOwnerMintedBurned(0, i + 34, nftSeq, 1, 0, 0, __LINE__); + } + + // Pay minter almost enough for the reserve of an additional NFT page. + env(pay(env.master, minter, XRP(50) + drops(319))); + env.close(); + + // That NFT page is full. Creating an additional NFT page requires + // additional reserve. + env(token::mint(minter), + token::issuer(alice), + token::uri("uri"), + ter(tecINSUFFICIENT_RESERVE)); + env.close(); + checkMintersOwnerMintedBurned(0, 65, nftSeq, 1, 0, 0, __LINE__); + + // Pay minter enough for the reserve of an additional NFT page. + env(pay(env.master, minter, drops(11))); + env.close(); + + // Now minter can mint an NFT. + env(token::mint(minter), token::issuer(alice), token::uri("uri")); + env.close(); + checkMintersOwnerMintedBurned(0, 66, nftSeq, 2, 0, 0, __LINE__); + + // minter burns the NFTs she created. + while (nftSeq < 65) + { + env(token::burn(minter, token::getID(alice, 0, nftSeq++))); + env.close(); + checkMintersOwnerMintedBurned( + 0, 66, nftSeq, (65 - seq) ? 1 : 0, 0, 0, __LINE__); + } + + // minter has one more NFT to burn. Should take her owner count to 0. + env(token::burn(minter, token::getID(alice, 0, nftSeq++))); + env.close(); + checkMintersOwnerMintedBurned(0, 66, nftSeq, 0, 0, 0, __LINE__); + + // minter burns a non-existent NFT. + env(token::burn(minter, token::getID(alice, 2009, 3)), + ter(tecNO_ENTRY)); + env.close(); + checkMintersOwnerMintedBurned(0, 66, nftSeq, 0, 0, 0, __LINE__); + } + + void + testMintMaxTokens(FeatureBitset features) + { + // Make sure that an account cannot cause the sfMintedNFTokens + // field to wrap by minting more than 0xFFFF'FFFF tokens. + testcase("Mint max tokens"); + + using namespace test::jtx; + + Account const alice{"alice"}; + Env env{*this, features}; + env.fund(XRP(1000), alice); + env.close(); + + // We're going to hack the ledger in order to avoid generating + // 4 billion or so NFTs. Because we're hacking the ledger we + // need alice's account to have non-zero sfMintedNFTokens and + // sfBurnedNFTokens fields. This prevents an exception when the + // AccountRoot template is applied. + { + uint256 const nftId0{token::getNextID(env, alice, 0u)}; + env(token::mint(alice, 0u)); + env.close(); + + env(token::burn(alice, nftId0)); + env.close(); + } + + // Note that we're bypassing almost all of the ledger's safety + // checks with this modify() call. If you call close() between + // here and the end of the test all the effort will be lost. + env.app().openLedger().modify( + [&alice](OpenView& view, beast::Journal j) { + // Get the account root we want to hijack. + auto const sle = view.read(keylet::account(alice.id())); + if (!sle) + return false; // This would be really surprising! + + // Just for sanity's sake we'll check that the current value + // of sfMintedNFTokens matches what we expect. + auto replacement = std::make_shared(*sle, sle->key()); + if (replacement->getFieldU32(sfMintedNFTokens) != 1) + return false; // Unexpected test conditions. + + // Now replace the sfMintedNFTokens with its maximum value. + (*replacement)[sfMintedNFTokens] = + std::numeric_limits::max(); + 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. + env(token::mint(alice, 0u), ter(tecMAX_SEQUENCE_REACHED)); + } + + void + testMintInvalid(FeatureBitset features) + { + // Explore many of the invalid ways to mint an NFT. + testcase("Mint invalid"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const minter{"minter"}; + + // Fund alice and minter enough to exist, but not enough to meet + // the reserve for creating their first NFT. Account reserve for unit + // tests is 200 XRP, not 20. + env.fund(XRP(200), alice, minter); + env.close(); + + env(token::mint(alice, 0u), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + // Fund alice enough to start minting NFTs. + env(pay(env.master, alice, XRP(1000))); + env.close(); + + //---------------------------------------------------------------------- + // preflight + + // Set a negative fee. + env(token::mint(alice, 0u), + fee(STAmount(10ull, true)), + ter(temBAD_FEE)); + + // Set an invalid flag. + env(token::mint(alice, 0u), txflags(0x00008000), ter(temINVALID_FLAG)); + + // Can't set a transfer fee if the NFT does not have the tfTRANSFERABLE + // flag set. + env(token::mint(alice, 0u), + token::xferFee(maxTransferFee), + ter(temMALFORMED)); + + // Set a bad transfer fee. + env(token::mint(alice, 0u), + token::xferFee(maxTransferFee + 1), + txflags(tfTransferable), + ter(temBAD_NFTOKEN_TRANSFER_FEE)); + + // Account can't also be issuer. + env(token::mint(alice, 0u), token::issuer(alice), ter(temMALFORMED)); + + // Invalid URI: zero length. + env(token::mint(alice, 0u), token::uri(""), ter(temMALFORMED)); + + // Invalid URI: too long. + env(token::mint(alice, 0u), + token::uri(std::string(maxTokenURILength + 1, 'q')), + ter(temMALFORMED)); + + //---------------------------------------------------------------------- + // preflight + + // Non-existent issuer. + env(token::mint(alice, 0u), + token::issuer(Account("demon")), + ter(tecNO_ISSUER)); + + //---------------------------------------------------------------------- + // doApply + + // Existent issuer, but not given minting permission + env(token::mint(minter, 0u), + token::issuer(alice), + ter(tecNO_PERMISSION)); + } + + void + testBurnInvalid(FeatureBitset features) + { + // Explore many of the invalid ways to burn an NFT. + testcase("Burn invalid"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const minter{"minter"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + + // Fund alice and minter enough to exist and create an NFT, but not + // enough to meet the reserve for creating their first NFTOffer. + // Account reserve for unit tests is 200 XRP, not 20. + env.fund(XRP(250), alice, buyer, minter, gw); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + uint256 const nftAlice0ID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + //---------------------------------------------------------------------- + // preflight + + // Set a negative fee. + env(token::burn(alice, nftAlice0ID), + fee(STAmount(10ull, true)), + ter(temBAD_FEE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // Set an invalid flag. + env(token::burn(alice, nftAlice0ID), + txflags(0x00008000), + ter(temINVALID_FLAG)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + //---------------------------------------------------------------------- + // preclaim + + // Try to burn a token that doesn't exist. + env(token::burn(alice, token::getID(alice, 0, 1)), ter(tecNO_ENTRY)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Can't burn a token with many buy or sell offers. But that is + // verified in testManyNftOffers(). + + //---------------------------------------------------------------------- + // doApply + } + + void + testCreateOfferInvalid(FeatureBitset features) + { + testcase("Invalid NFT offer create"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + + // Fund alice enough to exist and create an NFT, but not + // enough to meet the reserve for creating their first NFTOffer. + // Account reserve for unit tests is 200 XRP, not 20. + env.fund(XRP(250), alice, buyer, gw); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + uint256 const nftAlice0ID = + token::getNextID(env, alice, 0, tfTransferable, 10); + env(token::mint(alice, 0u), + txflags(tfTransferable), + token::xferFee(10)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + uint256 const nftXrpOnlyID = + token::getNextID(env, alice, 0, tfOnlyXRP | tfTransferable); + env(token::mint(alice, 0), txflags(tfOnlyXRP | tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + uint256 nftNoXferID = token::getNextID(env, alice, 0); + env(token::mint(alice, 0)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + //---------------------------------------------------------------------- + // preflight + + // buyer burns a fee, so they no longer have enough XRP to cover the + // reserve for a token offer. + env(noop(buyer)); + env.close(); + + // buyer tries to create an NFTokenOffer, but doesn't have the reserve. + env(token::createOffer(buyer, nftAlice0ID, XRP(1000)), + token::owner(alice), + ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Set a negative fee. + env(token::createOffer(buyer, nftAlice0ID, XRP(1000)), + fee(STAmount(10ull, true)), + ter(temBAD_FEE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Set an invalid flag. + env(token::createOffer(buyer, nftAlice0ID, XRP(1000)), + txflags(0x00008000), + ter(temINVALID_FLAG)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Set an invalid amount. + env(token::createOffer(buyer, nftXrpOnlyID, buyer["USD"](1)), + ter(temBAD_AMOUNT)); + env(token::createOffer(buyer, nftAlice0ID, buyer["USD"](0)), + ter(temBAD_AMOUNT)); + env(token::createOffer(buyer, nftXrpOnlyID, drops(0)), + ter(temBAD_AMOUNT)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Set a bad expiration. + env(token::createOffer(buyer, nftAlice0ID, buyer["USD"](1)), + token::expiration(0), + ter(temBAD_EXPIRATION)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Invalid Owner field and tfSellToken flag relationships. + // A buy offer must specify the owner. + env(token::createOffer(buyer, nftXrpOnlyID, XRP(1000)), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // A sell offer must not specify the owner; the owner is implicit. + env(token::createOffer(alice, nftXrpOnlyID, XRP(1000)), + token::owner(alice), + txflags(tfSellNFToken), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // An owner may not offer to buy their own token. + env(token::createOffer(alice, nftXrpOnlyID, XRP(1000)), + token::owner(alice), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // The destination may not be the account submitting the transaction. + env(token::createOffer(alice, nftXrpOnlyID, XRP(1000)), + token::destination(alice), + txflags(tfSellNFToken), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // The destination must be an account already established in the ledger. + env(token::createOffer(alice, nftXrpOnlyID, XRP(1000)), + token::destination(Account("demon")), + txflags(tfSellNFToken), + ter(tecNO_DST)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + //---------------------------------------------------------------------- + // preclaim + + // The new NFTokenOffer may not have passed its expiration time. + env(token::createOffer(buyer, nftXrpOnlyID, XRP(1000)), + token::owner(alice), + token::expiration(lastClose(env)), + ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // The nftID must be present in the ledger. + env(token::createOffer(buyer, token::getID(alice, 0, 1), XRP(1000)), + token::owner(alice), + ter(tecNO_ENTRY)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // The nftID must be present in the ledger of a sell offer too. + env(token::createOffer(alice, token::getID(alice, 0, 1), XRP(1000)), + txflags(tfSellNFToken), + ter(tecNO_ENTRY)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // buyer must have the funds to pay for their offer. + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecNO_LINE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + env(trust(buyer, gwAUD(1000))); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env.close(); + + // Issuer (alice) must have a trust line for the offered funds. + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecNO_LINE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Give alice the needed trust line, but freeze it. + env(trust(gw, alice["AUD"](999), tfSetFreeze)); + env.close(); + + // Issuer (alice) must have a trust line for the offered funds and + // the trust line may not be frozen. + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecFROZEN)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Unfreeze alice's trustline. + env(trust(gw, alice["AUD"](999), tfClearFreeze)); + env.close(); + + // Can't transfer the NFT if the transferable flag is not set. + env(token::createOffer(buyer, nftNoXferID, gwAUD(1000)), + token::owner(alice), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Give buyer the needed trust line, but freeze it. + env(trust(gw, buyer["AUD"](999), tfSetFreeze)); + env.close(); + + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecFROZEN)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Unfreeze buyer's trust line, but buyer has no actual gwAUD. + // to cover the offer. + env(trust(gw, buyer["AUD"](999), tfClearFreeze)); + env(trust(buyer, gwAUD(1000))); + env.close(); + + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecUNFUNDED_OFFER)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); // the trust line. + + //---------------------------------------------------------------------- + // doApply + + // Give buyer almost enough AUD to cover the offer... + env(pay(gw, buyer, gwAUD(999))); + env.close(); + + // However buyer doesn't have enough XRP to cover the reserve for + // an NFT offer. + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Give buyer almost enough XRP to cover the reserve. + env(pay(env.master, buyer, XRP(50) + drops(119))); + env.close(); + + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Give buyer just enough XRP to cover the reserve for the offer. + env(pay(env.master, buyer, drops(11))); + env.close(); + + // We don't care whether the offer is fully funded until the offer is + // accepted. Success at last! + env(token::createOffer(buyer, nftAlice0ID, gwAUD(1000)), + token::owner(alice), + ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + } + + void + testCancelOfferInvalid(FeatureBitset features) + { + testcase("Invalid NFT offer cancel"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + + env.fund(XRP(1000), alice, buyer, gw); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + uint256 const nftAlice0ID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // This is the offer we'll try to cancel. + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftAlice0ID, XRP(1)), + token::owner(alice), + ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + //---------------------------------------------------------------------- + // preflight + + // Set a negative fee. + env(token::cancelOffer(buyer, {buyerOfferIndex}), + fee(STAmount(10ull, true)), + ter(temBAD_FEE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Set an invalid flag. + env(token::cancelOffer(buyer, {buyerOfferIndex}), + txflags(0x00008000), + ter(temINVALID_FLAG)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Empty list of tokens to delete. + { + Json::Value jv = token::cancelOffer(buyer); + jv[sfNFTokenOffers.jsonName] = Json::arrayValue; + env(jv, ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + + // List of tokens to delete is too long. + { + std::vector offers( + maxTokenOfferCancelCount + 1, buyerOfferIndex); + + env(token::cancelOffer(buyer, offers), ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + + // Duplicate entries are not allowed in the list of offers to cancel. + env(token::cancelOffer(buyer, {buyerOfferIndex, buyerOfferIndex}), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Provide neither offers to cancel nor a root index. + env(token::cancelOffer(buyer), ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + //---------------------------------------------------------------------- + // preclaim + + // Make a non-root directory that we can pass as a root index. + env(pay(env.master, gw, XRP(5000))); + env.close(); + for (std::uint32_t i = 1; i < 34; ++i) + { + env(offer(gw, XRP(i), gwAUD(1))); + env.close(); + } + + { + // gw attempts to cancel a Check as through it is an NFTokenOffer. + auto const gwCheckId = keylet::check(gw, env.seq(gw)).key; + env(check::create(gw, env.master, XRP(300))); + env.close(); + + env(token::cancelOffer(gw, {gwCheckId}), ter(tecNO_PERMISSION)); + env.close(); + + // Cancel the check so it doesn't mess up later tests. + env(check::cancel(gw, gwCheckId)); + env.close(); + } + + // gw attempts to cancel an offer they don't have permission to cancel. + env(token::cancelOffer(gw, {buyerOfferIndex}), ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + //---------------------------------------------------------------------- + // doApply + // + // The tefBAD_LEDGER conditions are too hard to test. + // But let's see a successful offer cancel. + env(token::cancelOffer(buyer, {buyerOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + void + testAcceptOfferInvalid(FeatureBitset features) + { + testcase("Invalid NFT offer accept"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + + env.fund(XRP(1000), alice, buyer, gw); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + uint256 const nftAlice0ID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + uint256 const nftXrpOnlyID = + token::getNextID(env, alice, 0, tfOnlyXRP | tfTransferable); + env(token::mint(alice, 0), txflags(tfOnlyXRP | tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + uint256 nftNoXferID = token::getNextID(env, alice, 0); + env(token::mint(alice, 0)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // alice creates sell offers for her nfts. + uint256 const plainOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAlice0ID, XRP(10)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + uint256 const audOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAlice0ID, gwAUD(30)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + uint256 const xrpOnlyOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftXrpOnlyID, XRP(20)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 4); + + uint256 const noXferOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftNoXferID, XRP(30)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 5); + + // alice creates a sell offer that will expire soon. + uint256 const aliceExpOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftNoXferID, XRP(40)), + txflags(tfSellNFToken), + token::expiration(lastClose(env) + 5)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 6); + + //---------------------------------------------------------------------- + // preflight + + // Set a negative fee. + env(token::acceptSellOffer(buyer, noXferOfferIndex), + fee(STAmount(10ull, true)), + ter(temBAD_FEE)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Set an invalid flag. + env(token::acceptSellOffer(buyer, noXferOfferIndex), + txflags(0x00008000), + ter(temINVALID_FLAG)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Supply nether an sfNFTokenBuyOffer nor an sfNFTokenSellOffer field. + { + Json::Value jv = token::acceptSellOffer(buyer, noXferOfferIndex); + jv.removeMember(sfNFTokenSellOffer.jsonName); + env(jv, ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + // A buy offer may not contain a sfNFTokenBrokerFee field. + { + Json::Value jv = token::acceptBuyOffer(buyer, noXferOfferIndex); + jv[sfNFTokenBrokerFee.jsonName] = + STAmount(500000).getJson(JsonOptions::none); + env(jv, ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + // A sell offer may not contain a sfNFTokenBrokerFee field. + { + Json::Value jv = token::acceptSellOffer(buyer, noXferOfferIndex); + jv[sfNFTokenBrokerFee.jsonName] = + STAmount(500000).getJson(JsonOptions::none); + env(jv, ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + // A brokered offer may not contain a negative or zero brokerFee. + env(token::brokerOffers(buyer, noXferOfferIndex, xrpOnlyOfferIndex), + token::brokerFee(gwAUD(0)), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + //---------------------------------------------------------------------- + // preclaim + + // The buy offer must be present in the ledger. + uint256 const missingOfferIndex = keylet::nftoffer(alice, 1).key; + env(token::acceptBuyOffer(buyer, missingOfferIndex), + ter(tecOBJECT_NOT_FOUND)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // The buy offer must not have expired. + env(token::acceptBuyOffer(buyer, aliceExpOfferIndex), ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // The sell offer must be present in the ledger. + env(token::acceptSellOffer(buyer, missingOfferIndex), + ter(tecOBJECT_NOT_FOUND)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // The sell offer must not have expired. + env(token::acceptSellOffer(buyer, aliceExpOfferIndex), ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + //---------------------------------------------------------------------- + // preclaim brokered + + // alice and buyer need trustlines before buyer can to create an + // offer for gwAUD. + env(trust(alice, gwAUD(1000))); + env(trust(buyer, gwAUD(1000))); + env.close(); + env(pay(gw, buyer, gwAUD(30))); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 7); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // We're about to exercise offer brokering, so we need + // corresponding buy and sell offers. + { + // buyer creates a buy offer for one of alice's nfts. + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftAlice0ID, gwAUD(29)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // gw attempts to broker offers that are not for the same token. + env(token::brokerOffers(gw, buyerOfferIndex, xrpOnlyOfferIndex), + ter(tecNFTOKEN_BUY_SELL_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // gw attempts to broker offers that are not for the same currency. + env(token::brokerOffers(gw, buyerOfferIndex, plainOfferIndex), + ter(tecNFTOKEN_BUY_SELL_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // In a brokered offer, the buyer must offer greater than or + // equal to the selling price. + env(token::brokerOffers(gw, buyerOfferIndex, audOfferIndex), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Remove buyer's offer. + env(token::cancelOffer(buyer, {buyerOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + { + // buyer creates a buy offer for one of alice's nfts. + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftAlice0ID, gwAUD(31)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Broker sets their fee in a denomination other than the one + // used by the offers + env(token::brokerOffers(gw, buyerOfferIndex, audOfferIndex), + token::brokerFee(XRP(40)), + ter(tecNFTOKEN_BUY_SELL_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Broker fee way too big. + env(token::brokerOffers(gw, buyerOfferIndex, audOfferIndex), + token::brokerFee(gwAUD(31)), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Broker fee is smaller, but still too big once the offer + // seller's minimum is taken into account. + env(token::brokerOffers(gw, buyerOfferIndex, audOfferIndex), + token::brokerFee(gwAUD(1.5)), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Remove buyer's offer. + env(token::cancelOffer(buyer, {buyerOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + //---------------------------------------------------------------------- + // preclaim buy + { + // buyer creates a buy offer for one of alice's nfts. + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftAlice0ID, gwAUD(30)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Don't accept a buy offer if the sell flag is set. + env(token::acceptBuyOffer(buyer, plainOfferIndex), + ter(tecNFTOKEN_OFFER_TYPE_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 7); + + // An account can't accept its own offer. + env(token::acceptBuyOffer(buyer, buyerOfferIndex), + ter(tecCANT_ACCEPT_OWN_NFTOKEN_OFFER)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // An offer acceptor must have enough funds to pay for the offer. + env(pay(buyer, gw, gwAUD(30))); + env.close(); + BEAST_EXPECT(env.balance(buyer, gwAUD) == gwAUD(0)); + env(token::acceptBuyOffer(alice, buyerOfferIndex), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // alice gives her NFT to gw, so alice no longer owns nftAlice0. + { + uint256 const offerIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAlice0ID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(gw, offerIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 7); + } + env(pay(gw, buyer, gwAUD(30))); + env.close(); + + // alice can't accept a buy offer for an NFT she no longer owns. + env(token::acceptBuyOffer(alice, buyerOfferIndex), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Remove buyer's offer. + env(token::cancelOffer(buyer, {buyerOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + //---------------------------------------------------------------------- + // preclaim sell + { + // buyer creates a buy offer for one of alice's nfts. + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftXrpOnlyID, XRP(30)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Don't accept a sell offer without the sell flag set. + env(token::acceptSellOffer(alice, buyerOfferIndex), + ter(tecNFTOKEN_OFFER_TYPE_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 7); + + // An account can't accept its own offer. + env(token::acceptSellOffer(alice, plainOfferIndex), + ter(tecCANT_ACCEPT_OWN_NFTOKEN_OFFER)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // The seller must currently be in possession of the token they + // are selling. alice gave nftAlice0ID to gw. + env(token::acceptSellOffer(buyer, plainOfferIndex), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // gw gives nftAlice0ID back to alice. That allows us to check + // buyer attempting to accept one of alice's offers with + // insufficient funds. + { + uint256 const offerIndex = + keylet::nftoffer(gw, env.seq(gw)).key; + env(token::createOffer(gw, nftAlice0ID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(alice, offerIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 7); + } + env(pay(buyer, gw, gwAUD(30))); + env.close(); + BEAST_EXPECT(env.balance(buyer, gwAUD) == gwAUD(0)); + env(token::acceptSellOffer(buyer, audOfferIndex), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + } + + //---------------------------------------------------------------------- + // doApply + // + // As far as I can see none of the failure modes are accessible as + // long as the preflight and preclaim conditions are met. + } + + void + testMintFlagBurnable(FeatureBitset features) + { + // Exercise NFTs with flagBurnable set and not set. + testcase("Mint flagBurnable"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const minter1{"minter1"}; + Account const minter2{"minter2"}; + + env.fund(XRP(1000), alice, buyer, minter1, minter2); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + // alice selects minter as her minter. + env(token::setMinter(alice, minter1)); + env.close(); + + // A lambda that... + // 1. creates an alice nft + // 2. minted by minter and + // 3. transfers that nft to buyer. + auto nftToBuyer = [&env, &alice, &minter1, &buyer]( + std::uint32_t flags) { + uint256 const nftID{token::getNextID(env, alice, 0u, flags)}; + env(token::mint(minter1, 0u), token::issuer(alice), txflags(flags)); + env.close(); + + uint256 const offerIndex = + keylet::nftoffer(minter1, env.seq(minter1)).key; + env(token::createOffer(minter1, nftID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + + env(token::acceptSellOffer(buyer, offerIndex)); + env.close(); + + return nftID; + }; + + // An NFT without flagBurnable can only be burned by its owner. + { + uint256 const noBurnID = nftToBuyer(0); + env(token::burn(alice, noBurnID), + token::owner(buyer), + ter(tecNO_PERMISSION)); + env.close(); + env(token::burn(minter1, noBurnID), + token::owner(buyer), + ter(tecNO_PERMISSION)); + env.close(); + env(token::burn(minter2, noBurnID), + token::owner(buyer), + ter(tecNO_PERMISSION)); + env.close(); + + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::burn(buyer, noBurnID), token::owner(buyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // An NFT with flagBurnable can be burned by the issuer. + { + uint256 const burnableID = nftToBuyer(tfBurnable); + env(token::burn(minter2, burnableID), + token::owner(buyer), + ter(tecNO_PERMISSION)); + env.close(); + + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::burn(alice, burnableID), token::owner(buyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // An NFT with flagBurnable can be burned by the owner. + { + uint256 const burnableID = nftToBuyer(tfBurnable); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::burn(buyer, burnableID)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // An NFT with flagBurnable can be burned by the minter. + { + uint256 const burnableID = nftToBuyer(tfBurnable); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::burn(buyer, burnableID), token::owner(buyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // An nft with flagBurnable may be burned by the issuers' minter, + // who may not be the original minter. + { + uint256 const burnableID = nftToBuyer(tfBurnable); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + env(token::setMinter(alice, minter2)); + env.close(); + + // minter1 is no longer alice's minter, so no longer has + // permisson to burn alice's nfts. + env(token::burn(minter1, burnableID), + token::owner(buyer), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // minter2, however, can burn alice's nfts. + env(token::burn(minter2, burnableID), token::owner(buyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + } + + void + testMintFlagOnlyXRP(FeatureBitset features) + { + // Exercise NFTs with flagOnlyXRP set and not set. + testcase("Mint flagOnlyXRP"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const buyer{"buyer"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + + // Set trust lines so alice and buyer can use gwAUD. + env.fund(XRP(1000), alice, buyer, gw); + env.close(); + env(trust(alice, gwAUD(1000))); + env(trust(buyer, gwAUD(1000))); + env.close(); + env(pay(gw, buyer, gwAUD(100))); + + // Don't set flagOnlyXRP and offers can be made with IOUs. + { + uint256 const nftIOUsOkayID{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 2); + uint256 const aliceOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftIOUsOkayID, gwAUD(50)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + BEAST_EXPECT(ownerCount(env, buyer) == 1); + uint256 const buyerOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftIOUsOkayID, gwAUD(50)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Cancel the two offers just to be tidy. + env(token::cancelOffer(alice, {aliceOfferIndex})); + env(token::cancelOffer(buyer, {buyerOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Also burn alice's nft. + env(token::burn(alice, nftIOUsOkayID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + } + + // Set flagOnlyXRP and offers using IOUs are rejected. + { + uint256 const nftOnlyXRPID{ + token::getNextID(env, alice, 0u, tfOnlyXRP | tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfOnlyXRP | tfTransferable)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 2); + env(token::createOffer(alice, nftOnlyXRPID, gwAUD(50)), + txflags(tfSellNFToken), + ter(temBAD_AMOUNT)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::createOffer(buyer, nftOnlyXRPID, gwAUD(50)), + token::owner(alice), + ter(temBAD_AMOUNT)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // However offers for XRP are okay. + BEAST_EXPECT(ownerCount(env, alice) == 2); + env(token::createOffer(alice, nftOnlyXRPID, XRP(60)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 3); + + BEAST_EXPECT(ownerCount(env, buyer) == 1); + env(token::createOffer(buyer, nftOnlyXRPID, XRP(60)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + } + } + + void + testMintFlagCreateTrustLine(FeatureBitset features) + { + // Exercise NFTs with flagCreateTrustLines set and not set. + testcase("Mint flagCreateTrustLines"); + + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const becky{"becky"}; + Account const cheri{"cheri"}; + Account const gw("gw"); + IOU const gwAUD(gw["AUD"]); + IOU const gwCAD(gw["CAD"]); + IOU const gwEUR(gw["EUR"]); + + env.fund(XRP(1000), alice, becky, cheri, gw); + env.close(); + + // Set trust lines so becky and cheri can use gw's currency. + env(trust(becky, gwAUD(1000))); + env(trust(cheri, gwAUD(1000))); + env(trust(becky, gwCAD(1000))); + env(trust(cheri, gwCAD(1000))); + env(trust(becky, gwEUR(1000))); + env(trust(cheri, gwEUR(1000))); + env.close(); + env(pay(gw, becky, gwAUD(500))); + env(pay(gw, becky, gwCAD(500))); + env(pay(gw, becky, gwEUR(500))); + env(pay(gw, cheri, gwAUD(500))); + env(pay(gw, cheri, gwCAD(500))); + env.close(); + + // An nft without flagCreateTrustLines but with a non-zero transfer + // fee will not allow creating offers that use IOUs for payment. + for (std::uint32_t xferFee : {0, 1}) + { + uint256 const nftNoAutoTrustID{ + token::getNextID(env, alice, 0u, tfTransferable, xferFee)}; + env(token::mint(alice, 0u), + token::xferFee(xferFee), + txflags(tfTransferable)); + env.close(); + + // becky buys the nft for 1 drop. + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), + token::owner(alice)); + env.close(); + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + + // becky attempts to sell the nft for AUD. + TER const createOfferTER = + xferFee ? TER(tecNO_LINE) : TER(tesSUCCESS); + uint256 const beckyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)), + txflags(tfSellNFToken), + ter(createOfferTER)); + env.close(); + + // cheri offers to buy the nft for CAD. + uint256 const cheriOfferIndex = + keylet::nftoffer(cheri, env.seq(cheri)).key; + env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)), + token::owner(becky), + ter(createOfferTER)); + env.close(); + + // To keep things tidy, cancel the offers. + env(token::cancelOffer(becky, {beckyOfferIndex})); + env(token::cancelOffer(cheri, {cheriOfferIndex})); + env.close(); + } + // An nft with flagCreateTrustLines but with a non-zero transfer + // fee allows transfers using IOUs for payment. + { + std::uint16_t transferFee = 10000; // 10% + + uint256 const nftAutoTrustID{token::getNextID( + env, alice, 0u, tfTransferable | tfTrustLine, transferFee)}; + env(token::mint(alice, 0u), + token::xferFee(transferFee), + txflags(tfTransferable | tfTrustLine)); + env.close(); + + // becky buys the nft for 1 drop. + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftAutoTrustID, drops(1)), + token::owner(alice)); + env.close(); + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + + // becky sells the nft for AUD. + uint256 const beckySellOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(cheri, beckySellOfferIndex)); + env.close(); + + // alice should now have a trust line for gwAUD. + BEAST_EXPECT(env.balance(alice, gwAUD) == gwAUD(10)); + + // becky buys the nft back for CAD. + uint256 const beckyBuyBackOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftAutoTrustID, gwCAD(50)), + token::owner(cheri)); + env.close(); + env(token::acceptBuyOffer(cheri, beckyBuyBackOfferIndex)); + env.close(); + + // alice should now have a trust line for gwAUD and gwCAD. + BEAST_EXPECT(env.balance(alice, gwAUD) == gwAUD(10)); + BEAST_EXPECT(env.balance(alice, gwCAD) == gwCAD(5)); + } + // Now that alice has trust lines already established, an nft without + // flagCreateTrustLines will work for preestablished trust lines. + { + std::uint16_t transferFee = 5000; // 5% + uint256 const nftNoAutoTrustID{ + token::getNextID(env, alice, 0u, tfTransferable, transferFee)}; + env(token::mint(alice, 0u), + token::xferFee(transferFee), + txflags(tfTransferable)); + env.close(); + + // alice sells the nft using AUD. + uint256 const aliceSellOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftNoAutoTrustID, gwAUD(200)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(cheri, aliceSellOfferIndex)); + env.close(); + + // alice should now have AUD(210): + // o 200 for this sale and + // o 10 for the previous sale's fee. + BEAST_EXPECT(env.balance(alice, gwAUD) == gwAUD(210)); + + // cheri can't sell the NFT for EUR, but can for CAD. + env(token::createOffer(cheri, nftNoAutoTrustID, gwEUR(50)), + txflags(tfSellNFToken), + ter(tecNO_LINE)); + env.close(); + uint256 const cheriSellOfferIndex = + keylet::nftoffer(cheri, env.seq(cheri)).key; + env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(becky, cheriSellOfferIndex)); + env.close(); + + // alice should now have CAD(10): + // o 5 from this sale's fee and + // o 5 for the previous sale's fee. + BEAST_EXPECT(env.balance(alice, gwCAD) == gwCAD(10)); + } + } + + void + testMintFlagTransferable(FeatureBitset features) + { + // Exercise NFTs with flagTransferable set and not set. + testcase("Mint flagTransferable"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const becky{"becky"}; + Account const minter{"minter"}; + + env.fund(XRP(1000), alice, becky, minter); + env.close(); + + // First try an nft made by alice without flagTransferable set. + { + BEAST_EXPECT(ownerCount(env, alice) == 0); + uint256 const nftAliceNoTransferID{ + token::getNextID(env, alice, 0u)}; + env(token::mint(alice, 0u), token::xferFee(0)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // becky tries to offer to buy alice's nft. + BEAST_EXPECT(ownerCount(env, becky) == 0); + env(token::createOffer(becky, nftAliceNoTransferID, XRP(20)), + token::owner(alice), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + + // alice offers to sell the nft and becky accepts the offer. + uint256 const aliceSellOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAliceNoTransferID, XRP(20)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(becky, aliceSellOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + + // becky tries to offer the nft for sale. + env(token::createOffer(becky, nftAliceNoTransferID, XRP(21)), + txflags(tfSellNFToken), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + + // becky tries to offer the nft for sale with alice as the + // destination. That also doesn't work. + env(token::createOffer(becky, nftAliceNoTransferID, XRP(21)), + txflags(tfSellNFToken), + token::destination(alice), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + + // alice offers to buy the nft back from becky. becky accepts + // the offer. + uint256 const aliceBuyOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAliceNoTransferID, XRP(22)), + token::owner(becky)); + env.close(); + env(token::acceptBuyOffer(becky, aliceBuyOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 0); + + // alice burns her nft so accounting is simpler below. + env(token::burn(alice, nftAliceNoTransferID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 0); + } + // Try an nft minted by minter for alice without flagTransferable set. + { + env(token::setMinter(alice, minter)); + env.close(); + + BEAST_EXPECT(ownerCount(env, minter) == 0); + uint256 const nftMinterNoTransferID{ + token::getNextID(env, alice, 0u)}; + env(token::mint(minter), token::issuer(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + // becky tries to offer to buy minter's nft. + BEAST_EXPECT(ownerCount(env, becky) == 0); + env(token::createOffer(becky, nftMinterNoTransferID, XRP(20)), + token::owner(minter), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, becky) == 0); + + // alice removes authorization of minter. + env(token::clearMinter(alice)); + env.close(); + + // minter tries to offer their nft for sale. + BEAST_EXPECT(ownerCount(env, minter) == 1); + env(token::createOffer(minter, nftMinterNoTransferID, XRP(21)), + txflags(tfSellNFToken), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + // Let enough ledgers pass that old transactions are no longer + // retried, then alice gives authorization back to minter. + for (int i = 0; i < 10; ++i) + env.close(); + + env(token::setMinter(alice, minter)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + // minter successfully offers their nft for sale. + BEAST_EXPECT(ownerCount(env, minter) == 1); + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftMinterNoTransferID, XRP(22)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 2); + + // alice removes authorization of minter so we can see whether + // minter's pre-existing offer still works. + env(token::clearMinter(alice)); + env.close(); + + // becky buys minter's nft even though minter is no longer alice's + // official minter. + BEAST_EXPECT(ownerCount(env, becky) == 0); + env(token::acceptSellOffer(becky, minterSellOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 0); + + // becky attempts to sell the nft. + env(token::createOffer(becky, nftMinterNoTransferID, XRP(23)), + txflags(tfSellNFToken), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + + // Since minter is not, at the moment, alice's official minter + // they cannot create an offer to buy the nft they minted. + BEAST_EXPECT(ownerCount(env, minter) == 0); + env(token::createOffer(minter, nftMinterNoTransferID, XRP(24)), + token::owner(becky), + ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 0); + + // alice can create an offer to buy the nft. + BEAST_EXPECT(ownerCount(env, alice) == 0); + uint256 const aliceBuyOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftMinterNoTransferID, XRP(25)), + token::owner(becky)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // Let enough ledgers pass that old transactions are no longer + // retried, then alice gives authorization back to minter. + for (int i = 0; i < 10; ++i) + env.close(); + + env(token::setMinter(alice, minter)); + env.close(); + + // Now minter can create an offer to buy the nft. + BEAST_EXPECT(ownerCount(env, minter) == 0); + uint256 const minterBuyOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftMinterNoTransferID, XRP(26)), + token::owner(becky)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + // alice removes authorization of minter so we can see whether + // minter's pre-existing buy offer still works. + env(token::clearMinter(alice)); + env.close(); + + // becky accepts minter's sell offer. + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + env(token::acceptBuyOffer(becky, minterBuyOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 0); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // minter burns their nft and alice cancels her offer so the + // next tests can start with a clean slate. + env(token::burn(minter, nftMinterNoTransferID), ter(tesSUCCESS)); + env.close(); + env(token::cancelOffer(alice, {aliceBuyOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + } + // nfts with flagTransferable set should be buyable and salable + // by anybody. + { + BEAST_EXPECT(ownerCount(env, alice) == 0); + uint256 const nftAliceID{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // Both alice and becky can make offers for alice's nft. + uint256 const aliceSellOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftAliceID, XRP(20)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftAliceID, XRP(21)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + // becky accepts alice's sell offer. + env(token::acceptSellOffer(becky, aliceSellOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 2); + + // becky offers to sell the nft. + uint256 const beckySellOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftAliceID, XRP(22)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 3); + + // minter buys the nft (even though minter is not currently + // alice's minter). + env(token::acceptSellOffer(minter, beckySellOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + // minter offers to sell the nft. + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftAliceID, XRP(23)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 2); + + // alice buys back the nft. + env(token::acceptSellOffer(alice, minterSellOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 0); + + // Remember the buy offer that becky made for alice's token way + // back when? It's still in the ledger, and alice accepts it. + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 0); + + // Just for tidyness, becky burns the token before shutting + // things down. + env(token::burn(becky, nftAliceID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, becky) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + } + } + + void + testMintTransferFee(FeatureBitset features) + { + // Exercise NFTs with and without a transferFee. + testcase("Mint transferFee"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const becky{"becky"}; + Account const carol{"carol"}; + Account const minter{"minter"}; + Account const gw{"gw"}; + IOU const gwXAU(gw["XAU"]); + + env.fund(XRP(1000), alice, becky, carol, minter, gw); + env.close(); + + env(trust(alice, gwXAU(2000))); + env(trust(becky, gwXAU(2000))); + env(trust(carol, gwXAU(2000))); + env(trust(minter, gwXAU(2000))); + env.close(); + env(pay(gw, alice, gwXAU(1000))); + env(pay(gw, becky, gwXAU(1000))); + env(pay(gw, carol, gwXAU(1000))); + env(pay(gw, minter, gwXAU(1000))); + env.close(); + + // Giving alice a minter helps us see if transfer rates are affected + // by that. + env(token::setMinter(alice, minter)); + env.close(); + + // If there is no transferFee, then alice gets nothing for the + // transfer. + { + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, carol) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + + uint256 const nftID = + token::getNextID(env, alice, 0u, tfTransferable); + env(token::mint(alice), txflags(tfTransferable)); + env.close(); + + // Becky buys the nft for XAU(10). Check balances. + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(10)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); + + // becky sells nft to carol. alice's balance should not change. + uint256 const beckySellOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(10)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(carol, beckySellOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990)); + + // minter buys nft from carol. alice's balance should not change. + uint256 const minterBuyOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(10)), + token::owner(carol)); + env.close(); + env(token::acceptBuyOffer(carol, minterBuyOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(990)); + + // minter sells the nft to alice. gwXAU balances should finish + // where they started. + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(10)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(alice, minterSellOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + + // alice burns the nft to make later tests easier to think about. + env(token::burn(alice, nftID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, carol) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + } + + // Set the smallest possible transfer fee. + { + // An nft with a transfer fee of 1 basis point. + uint256 const nftID = + token::getNextID(env, alice, 0u, tfTransferable, 1); + env(token::mint(alice), txflags(tfTransferable), token::xferFee(1)); + env.close(); + + // Becky buys the nft for XAU(10). Check balances. + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(10)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); + + // becky sells nft to carol. alice's balance goes up. + uint256 const beckySellOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(10)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(carol, beckySellOfferIndex)); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010.0001)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(999.9999)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990)); + + // minter buys nft from carol. alice's balance goes up. + uint256 const minterBuyOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(10)), + token::owner(carol)); + env.close(); + env(token::acceptBuyOffer(carol, minterBuyOfferIndex)); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010.0002)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(999.9999)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(999.9999)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(990)); + + // minter sells the nft to alice. Because alice is part of the + // transaction no tranfer fee is removed. + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(10)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(alice, minterSellOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000.0002)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(999.9999)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(999.9999)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + + // alice pays to becky and carol so subsequent tests are easier + // to think about. + env(pay(alice, becky, gwXAU(0.0001))); + env(pay(alice, carol, gwXAU(0.0001))); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + + // alice burns the nft to make later tests easier to think about. + env(token::burn(alice, nftID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, carol) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + } + + // Set the largest allowed transfer fee. + { + // A transfer fee greater than 50% is not allowed. + env(token::mint(alice), + txflags(tfTransferable), + token::xferFee(maxTransferFee + 1), + ter(temBAD_NFTOKEN_TRANSFER_FEE)); + env.close(); + + // Make an nft with a transfer fee of 50%. + uint256 const nftID = token::getNextID( + env, alice, 0u, tfTransferable, maxTransferFee); + env(token::mint(alice), + txflags(tfTransferable), + token::xferFee(maxTransferFee)); + env.close(); + + // Becky buys the nft for XAU(10). Check balances. + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(10)), + token::owner(alice)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + + env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1010)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); + + // becky sells nft to minter. alice's balance goes up. + uint256 const beckySellOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, gwXAU(100)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(minter, beckySellOfferIndex)); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1060)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1040)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900)); + + // carol buys nft from minter. alice's balance goes up. + uint256 const carolBuyOfferIndex = + keylet::nftoffer(carol, env.seq(carol)).key; + env(token::createOffer(carol, nftID, gwXAU(10)), + token::owner(minter)); + env.close(); + env(token::acceptBuyOffer(minter, carolBuyOfferIndex)); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1065)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1040)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(905)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990)); + + // carol sells the nft to alice. Because alice is part of the + // transaction no tranfer fee is removed. + uint256 const carolSellOfferIndex = + keylet::nftoffer(carol, env.seq(carol)).key; + env(token::createOffer(carol, nftID, gwXAU(10)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(alice, carolSellOfferIndex)); + env.close(); + + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1055)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1040)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(905)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(1000)); + + // rebalance so subsequent tests are easier to think about. + env(pay(alice, minter, gwXAU(55))); + env(pay(becky, minter, gwXAU(40))); + env.close(); + BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + + // alice burns the nft to make later tests easier to think about. + env(token::burn(alice, nftID)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, becky) == 1); + BEAST_EXPECT(ownerCount(env, carol) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + } + + // See the impact of rounding when the nft is sold for small amounts + // of drops. + { + // An nft with a transfer fee of 1 basis point. + uint256 const nftID = + token::getNextID(env, alice, 0u, tfTransferable, 1); + env(token::mint(alice), txflags(tfTransferable), token::xferFee(1)); + env.close(); + + // minter buys the nft for XRP(1). Since the transfer involves + // alice there should be no transfer fee. + STAmount fee = drops(10); + STAmount aliceBalance = env.balance(alice); + STAmount minterBalance = env.balance(minter); + uint256 const minterBuyOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, XRP(1)), token::owner(alice)); + env.close(); + env(token::acceptBuyOffer(alice, minterBuyOfferIndex)); + env.close(); + aliceBalance += XRP(1) - fee; + minterBalance -= XRP(1) + fee; + BEAST_EXPECT(env.balance(alice) == aliceBalance); + BEAST_EXPECT(env.balance(minter) == minterBalance); + + // minter sells to carol. The payment is just small enough that + // alice does not get any transfer fee. + STAmount carolBalance = env.balance(carol); + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, drops(99999)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(carol, minterSellOfferIndex)); + env.close(); + minterBalance += drops(99999) - fee; + carolBalance -= drops(99999) + fee; + BEAST_EXPECT(env.balance(alice) == aliceBalance); + BEAST_EXPECT(env.balance(minter) == minterBalance); + BEAST_EXPECT(env.balance(carol) == carolBalance); + + // carol sells to becky. This is the smallest amount to pay for a + // transfer that enables a transfer fee of 1 basis point. + STAmount beckyBalance = env.balance(becky); + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, drops(100000)), + token::owner(carol)); + env.close(); + env(token::acceptBuyOffer(carol, beckyBuyOfferIndex)); + env.close(); + carolBalance += drops(99999) - fee; + beckyBalance -= drops(100000) + fee; + aliceBalance += drops(1); + + BEAST_EXPECT(env.balance(alice) == aliceBalance); + BEAST_EXPECT(env.balance(minter) == minterBalance); + BEAST_EXPECT(env.balance(carol) == carolBalance); + BEAST_EXPECT(env.balance(becky) == beckyBalance); + } + + // See the impact of rounding when the nft is sold for small amounts + // of an IOU. + { + // An nft with a transfer fee of 1 basis point. + uint256 const nftID = + token::getNextID(env, alice, 0u, tfTransferable, 1); + env(token::mint(alice), txflags(tfTransferable), token::xferFee(1)); + env.close(); + + // Due to the floating point nature of IOUs we need to + // significantly reduce the gwXAU balances of our accounts prior + // to the iou transfer. Otherwise no transfers will happen. + env(pay(alice, gw, env.balance(alice, gwXAU))); + env(pay(minter, gw, env.balance(minter, gwXAU))); + env(pay(becky, gw, env.balance(becky, gwXAU))); + env.close(); + + STAmount const startXAUBalance( + gwXAU.issue(), STAmount::cMinValue, STAmount::cMinOffset + 5); + env(pay(gw, alice, startXAUBalance)); + env(pay(gw, minter, startXAUBalance)); + env(pay(gw, becky, startXAUBalance)); + env.close(); + + // Here is the smallest expressible gwXAU amount. + STAmount tinyXAU( + gwXAU.issue(), STAmount::cMinValue, STAmount::cMinOffset); + + // minter buys the nft for tinyXAU. Since the transfer involves + // alice there should be no transfer fee. + STAmount aliceBalance = env.balance(alice, gwXAU); + STAmount minterBalance = env.balance(minter, gwXAU); + uint256 const minterBuyOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, tinyXAU), + token::owner(alice)); + env.close(); + env(token::acceptBuyOffer(alice, minterBuyOfferIndex)); + env.close(); + aliceBalance += tinyXAU; + minterBalance -= tinyXAU; + BEAST_EXPECT(env.balance(alice, gwXAU) == aliceBalance); + BEAST_EXPECT(env.balance(minter, gwXAU) == minterBalance); + + // minter sells to carol. + STAmount carolBalance = env.balance(carol, gwXAU); + uint256 const minterSellOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, tinyXAU), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(carol, minterSellOfferIndex)); + env.close(); + + minterBalance += tinyXAU; + carolBalance -= tinyXAU; + // tiny XAU is so small that alice does not get a transfer fee. + BEAST_EXPECT(env.balance(alice, gwXAU) == aliceBalance); + BEAST_EXPECT(env.balance(minter, gwXAU) == minterBalance); + BEAST_EXPECT(env.balance(carol, gwXAU) == carolBalance); + + // carol sells to becky. This is the smallest gwXAU amount + // to pay for a transfer that enables a transfer fee of 1. + STAmount const cheapNFT( + gwXAU.issue(), STAmount::cMinValue, STAmount::cMinOffset + 5); + + STAmount beckyBalance = env.balance(becky, gwXAU); + uint256 const beckyBuyOfferIndex = + keylet::nftoffer(becky, env.seq(becky)).key; + env(token::createOffer(becky, nftID, cheapNFT), + token::owner(carol)); + env.close(); + env(token::acceptBuyOffer(carol, beckyBuyOfferIndex)); + env.close(); + + aliceBalance += tinyXAU; + beckyBalance -= cheapNFT; + carolBalance += cheapNFT - tinyXAU; + BEAST_EXPECT(env.balance(alice, gwXAU) == aliceBalance); + BEAST_EXPECT(env.balance(minter, gwXAU) == minterBalance); + BEAST_EXPECT(env.balance(carol, gwXAU) == carolBalance); + BEAST_EXPECT(env.balance(becky, gwXAU) == beckyBalance); + } + } + + void + testMintTaxon(FeatureBitset features) + { + // Exercise the NFT taxon field. + testcase("Mint taxon"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const becky{"becky"}; + + env.fund(XRP(1000), alice, becky); + env.close(); + + // The taxon field is incorporated straight into the NFT ID. So + // tests only need to operate on NFT IDs; we don't need to generate + // any transactions. + + // The taxon value should be recoverable from the NFT ID. + { + uint256 const nftID = token::getNextID(env, alice, 0u); + BEAST_EXPECT(nft::getTaxon(nftID) == nft::toTaxon(0)); + } + + // Make sure the full range of taxon values work. We just tried + // the minimum. Now try the largest. + { + uint256 const nftID = token::getNextID(env, alice, 0xFFFFFFFFu); + BEAST_EXPECT(nft::getTaxon(nftID) == nft::toTaxon((0xFFFFFFFF))); + } + + // Do some touch testing to show that the taxon is recoverable no + // matter what else changes around it in the nft ID. + { + std::uint32_t const taxon = rand_int(); + for (int i = 0; i < 10; ++i) + { + // lambda to produce a useful message on error. + auto check = [this](std::uint32_t taxon, uint256 const& nftID) { + nft::Taxon const gotTaxon = nft::getTaxon(nftID); + if (nft::toTaxon(taxon) == gotTaxon) + pass(); + else + { + std::stringstream ss; + ss << "Taxon recovery failed from nftID " + << to_string(nftID) << ". Expected: " << taxon + << "; got: " << gotTaxon; + fail(ss.str()); + } + }; + + uint256 const nftAliceID = token::getID( + alice, + taxon, + rand_int(), + rand_int(), + rand_int()); + check(taxon, nftAliceID); + + uint256 const nftBeckyID = token::getID( + becky, + taxon, + rand_int(), + rand_int(), + rand_int()); + check(taxon, nftBeckyID); + } + } + } + + void + testMintURI(FeatureBitset features) + { + // Exercise the NFT URI field. + // 1. Create a number of NFTs with and without URIs. + // 2. Retrieve the NFTs from the server. + // 3. Make sure the right URI is attached to each NFT. + testcase("Mint URI"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const becky{"becky"}; + + env.fund(XRP(10000), alice, becky); + env.close(); + + // lambda that returns a randomly generated string which fits + // the constraints of a URI. Empty strings may be returned. + // In the empty string case do not add the URI to the nft. + auto randURI = []() { + std::string ret; + + // About 20% of the returned strings should be empty + if (rand_int(4) == 0) + return ret; + + std::size_t const strLen = rand_int(256); + ret.reserve(strLen); + for (std::size_t i = 0; i < strLen; ++i) + ret.push_back(rand_byte()); + + return ret; + }; + + // Make a list of URIs that we'll put in nfts. + struct Entry + { + std::string uri; + std::uint32_t taxon; + + Entry(std::string uri_, std::uint32_t taxon_) + : uri(std::move(uri_)), taxon(taxon_) + { + } + }; + + std::vector entries; + entries.reserve(100); + for (std::size_t i = 0; i < 100; ++i) + entries.emplace_back(randURI(), rand_int()); + + // alice creates nfts using entries. + for (Entry const& entry : entries) + { + if (entry.uri.empty()) + { + env(token::mint(alice, entry.taxon)); + } + else + { + env(token::mint(alice, entry.taxon), token::uri(entry.uri)); + } + env.close(); + } + + // Recover alice's nfts from the ledger. + Json::Value aliceNFTs = [&env, &alice]() { + Json::Value params; + params[jss::account] = alice.human(); + params[jss::type] = "state"; + return env.rpc("json", "account_nfts", to_string(params)); + }(); + + // Verify that the returned NFTs match what we sent. + Json::Value& nfts = aliceNFTs[jss::result][jss::account_nfts]; + if (!BEAST_EXPECT(nfts.size() == entries.size())) + return; + + // Sort the returned NFTs by nft_serial so the are in the same order + // as entries. + std::vector sortedNFTs; + sortedNFTs.reserve(nfts.size()); + for (std::size_t i = 0; i < nfts.size(); ++i) + sortedNFTs.push_back(nfts[i]); + std::sort( + sortedNFTs.begin(), + sortedNFTs.end(), + [](Json::Value const& lhs, Json::Value const& rhs) { + return lhs[jss::nft_serial] < rhs[jss::nft_serial]; + }); + + for (std::size_t i = 0; i < entries.size(); ++i) + { + Entry const& entry = entries[i]; + Json::Value const& ret = sortedNFTs[i]; + BEAST_EXPECT(entry.taxon == ret[sfNFTokenTaxon.jsonName]); + if (entry.uri.empty()) + { + BEAST_EXPECT(!ret.isMember(sfURI.jsonName)); + } + else + { + BEAST_EXPECT(strHex(entry.uri) == ret[sfURI.jsonName]); + } + } + } + + void + testCreateOfferDestination(FeatureBitset features) + { + // Explore the CreateOffer Destination field. + testcase("Create offer destination"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const minter{"minter"}; + Account const buyer{"buyer"}; + Account const broker{"broker"}; + + env.fund(XRP(1000), issuer, minter, buyer, broker); + + // We want to explore how issuers vs minters fits into the permission + // scheme. So issuer issues and minter mints. + env(token::setMinter(issuer, minter)); + env.close(); + + uint256 const nftokenID = + token::getNextID(env, issuer, 0, tfTransferable); + env(token::mint(minter, 0), + token::issuer(issuer), + txflags(tfTransferable)); + env.close(); + + // Test how adding a Destination field to an offer affects permissions + // for cancelling offers. + { + uint256 const offerMinterToIssuer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::destination(issuer), + txflags(tfSellNFToken)); + + uint256 const offerMinterToBuyer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::destination(buyer), + txflags(tfSellNFToken)); + + // buy offers cannot contain a Destination, so this attempt fails. + env(token::createOffer(issuer, nftokenID, drops(1)), + token::owner(minter), + token::destination(minter), + ter(temMALFORMED)); + + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Test who gets to cancel the offers. Anyone outside of the + // offer-owner/destination pair should not be able to cancel the + // offers. + // + // Note that issuer does not have any special permissions regarding + // offer cancellation. issuer cannot cancel an offer for an + // NFToken they issued. + env(token::cancelOffer(issuer, {offerMinterToBuyer}), + ter(tecNO_PERMISSION)); + env(token::cancelOffer(buyer, {offerMinterToIssuer}), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Both the offer creator and and destination should be able to + // cancel the offers. + env(token::cancelOffer(buyer, {offerMinterToBuyer})); + env(token::cancelOffer(minter, {offerMinterToIssuer})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + // Test how adding a Destination field to a sell offer affects + // accepting that offer. + { + uint256 const offerMinterToBuyer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::destination(buyer), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // issuer cannot accept a sell offer where they are not the + // destination. + env(token::acceptSellOffer(issuer, offerMinterToBuyer), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // However buyer can accept the sell offer. + env(token::acceptSellOffer(buyer, offerMinterToBuyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + + // You can't add a Destination field to a buy offer. + { + env(token::createOffer(minter, nftokenID, drops(1)), + token::owner(buyer), + token::destination(buyer), + ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // However without the Destination the buy offer works fine. + uint256 const offerMinterToBuyer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::owner(buyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Buyer accepts minter's offer. + env(token::acceptBuyOffer(buyer, offerMinterToBuyer)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + + // Show that a sell offer's Destination can broker that sell offer + // to another account. + { + uint256 const offerMinterToBroker = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::destination(broker), + txflags(tfSellNFToken)); + + uint256 const offerBuyerToMinter = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID, drops(1)), + token::owner(minter)); + + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // issuer cannot broker the offers, because they are not the + // Destination. + env(token::brokerOffers( + issuer, offerBuyerToMinter, offerMinterToBroker), + ter(tecNFTOKEN_BUY_SELL_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Since broker is the sell offer's destination, they can broker + // the two offers. + env(token::brokerOffers( + broker, offerBuyerToMinter, offerMinterToBroker)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 0); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + } + + // Show that brokered mode cannot complete a transfer where the + // Destination doesn't match, but can complete if the Destination + // does match. + { + uint256 const offerBuyerToMinter = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID, drops(1)), + token::destination(minter), + txflags(tfSellNFToken)); + + uint256 const offerMinterToBuyer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID, drops(1)), + token::owner(buyer)); + + uint256 const offerIssuerToBuyer = + keylet::nftoffer(issuer, env.seq(issuer)).key; + env(token::createOffer(issuer, nftokenID, drops(1)), + token::owner(buyer)); + + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Cannot broker offers when the sell destination is not the buyer. + env(token::brokerOffers( + broker, offerIssuerToBuyer, offerBuyerToMinter), + ter(tecNFTOKEN_BUY_SELL_MISMATCH)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Broker is successful when destination is buyer. + env(token::brokerOffers( + broker, offerMinterToBuyer, offerBuyerToMinter)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + } + + void + testCreateOfferExpiration(FeatureBitset features) + { + // Explore the CreateOffer Expiration field. + testcase("Create offer expiration"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const minter{"minter"}; + Account const buyer{"buyer"}; + + env.fund(XRP(1000), issuer, minter, buyer); + + // We want to explore how issuers vs minters fits into the permission + // scheme. So issuer issues and minter mints. + env(token::setMinter(issuer, minter)); + env.close(); + + uint256 const nftokenID0 = + token::getNextID(env, issuer, 0, tfTransferable); + env(token::mint(minter, 0), + token::issuer(issuer), + txflags(tfTransferable)); + env.close(); + + uint256 const nftokenID1 = + token::getNextID(env, issuer, 0, tfTransferable); + env(token::mint(minter, 0), + token::issuer(issuer), + txflags(tfTransferable)); + env.close(); + + // Test how adding an Expiration field to an offer affects permissions + // for cancelling offers. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const offerMinterToIssuer = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + token::destination(issuer), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const offerMinterToAnyone = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const offerIssuerToMinter = + keylet::nftoffer(issuer, env.seq(issuer)).key; + env(token::createOffer(issuer, nftokenID0, drops(1)), + token::owner(minter), + token::expiration(expiration)); + + uint256 const offerBuyerToMinter = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, drops(1)), + token::owner(minter), + token::expiration(expiration)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Test who gets to cancel the offers. Anyone outside of the + // offer-owner/destination pair should not be able to cancel + // unexpired offers. + // + // Note that these are tec responses, so these transactions will + // not be retried by the ledger. + env(token::cancelOffer(issuer, {offerMinterToAnyone}), + ter(tecNO_PERMISSION)); + env(token::cancelOffer(buyer, {offerIssuerToMinter}), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // The offer creator can cancel their own unexpired offer. + env(token::cancelOffer(minter, {offerMinterToAnyone})); + + // The destination of a sell offer can cancel the NFT owner's + // unexpired offer. + env(token::cancelOffer(issuer, {offerMinterToIssuer})); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Anyone can cancel expired offers. + env(token::cancelOffer(issuer, {offerBuyerToMinter})); + env(token::cancelOffer(buyer, {offerIssuerToMinter})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // Show that: + // 1. An unexpired sell offer with an expiration can be accepted. + // 2. An expired sell offer cannot be accepted and remains + // in ledger after the accept fails. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const offer0 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const offer1 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID1, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + + // Anyone can accept an unexpired sell offer. + env(token::acceptSellOffer(buyer, offer0)); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // No one can accept an expired sell offer. + env(token::acceptSellOffer(buyer, offer1), ter(tecEXPIRED)); + env(token::acceptSellOffer(issuer, offer1), ter(tecEXPIRED)); + env.close(); + + // The expired sell offer is still in the ledger. + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Anyone can cancel the expired sell offer. + env(token::cancelOffer(issuer, {offer1})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Transfer nftokenID0 back to minter so we start the next test in + // a simple place. + uint256 const offerSellBack = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, XRP(0)), + txflags(tfSellNFToken), + token::destination(minter)); + env.close(); + env(token::acceptSellOffer(minter, offerSellBack)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // Show that: + // 1. An unexpired buy offer with an expiration can be accepted. + // 2. An expired buy offer cannot be accepted and remains + // in ledger after the accept fails. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const offer0 = keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, drops(1)), + token::owner(minter), + token::expiration(expiration)); + + uint256 const offer1 = keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID1, drops(1)), + token::owner(minter), + token::expiration(expiration)); + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // An unexpired buy offer can be accepted. + env(token::acceptBuyOffer(minter, offer0)); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // An expired buy offer cannot be accepted. + env(token::acceptBuyOffer(minter, offer1), ter(tecEXPIRED)); + env(token::acceptBuyOffer(issuer, offer1), ter(tecEXPIRED)); + env.close(); + + // The expired buy offer is still in the ledger. + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Anyone can cancel the expired buy offer. + env(token::cancelOffer(issuer, {offer1})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Transfer nftokenID0 back to minter so we start the next test in + // a simple place. + uint256 const offerSellBack = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, XRP(0)), + txflags(tfSellNFToken), + token::destination(minter)); + env.close(); + env(token::acceptSellOffer(minter, offerSellBack)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // Show that in brokered mode: + // 1. An unexpired sell offer with an expiration can be accepted. + // 2. An expired sell offer cannot be accepted and remains + // in ledger after the accept fails. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const sellOffer0 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const sellOffer1 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID1, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const buyOffer0 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, drops(1)), + token::owner(minter)); + + uint256 const buyOffer1 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID1, drops(1)), + token::owner(minter)); + + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // An unexpired offer can be brokered. + env(token::brokerOffers(issuer, buyOffer0, sellOffer0)); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // If the sell offer is expired it cannot be brokered. + env(token::brokerOffers(issuer, buyOffer1, sellOffer1), + ter(tecEXPIRED)); + env.close(); + + // The expired sell offer is still in the ledger. + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Anyone can cancel the expired sell offer. + env(token::cancelOffer(buyer, {buyOffer1, sellOffer1})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Transfer nftokenID0 back to minter so we start the next test in + // a simple place. + uint256 const offerSellBack = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, XRP(0)), + txflags(tfSellNFToken), + token::destination(minter)); + env.close(); + env(token::acceptSellOffer(minter, offerSellBack)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // Show that in brokered mode: + // 1. An unexpired buy offer with an expiration can be accepted. + // 2. An expired buy offer cannot be accepted and remains + // in ledger after the accept fails. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const sellOffer0 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + txflags(tfSellNFToken)); + + uint256 const sellOffer1 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID1, drops(1)), + txflags(tfSellNFToken)); + + uint256 const buyOffer0 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, drops(1)), + token::expiration(expiration), + token::owner(minter)); + + uint256 const buyOffer1 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID1, drops(1)), + token::expiration(expiration), + token::owner(minter)); + + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // An unexpired offer can be brokered. + env(token::brokerOffers(issuer, buyOffer0, sellOffer0)); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // If the buy offer is expired it cannot be brokered. + env(token::brokerOffers(issuer, buyOffer1, sellOffer1), + ter(tecEXPIRED)); + env.close(); + + // The expired buy offer is still in the ledger. + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Anyone can cancel the expired buy offer. + env(token::cancelOffer(minter, {buyOffer1, sellOffer1})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Transfer nftokenID0 back to minter so we start the next test in + // a simple place. + uint256 const offerSellBack = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, XRP(0)), + txflags(tfSellNFToken), + token::destination(minter)); + env.close(); + env(token::acceptSellOffer(minter, offerSellBack)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + // Show that in brokered mode: + // 1. An unexpired buy/sell offer pair with an expiration can be + // accepted. + // 2. An expired buy/sell offer pair cannot be accepted and they + // remain in ledger after the accept fails. + { + std::uint32_t const expiration = lastClose(env) + 25; + + uint256 const sellOffer0 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID0, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const sellOffer1 = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftokenID1, drops(1)), + token::expiration(expiration), + txflags(tfSellNFToken)); + + uint256 const buyOffer0 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, drops(1)), + token::expiration(expiration), + token::owner(minter)); + + uint256 const buyOffer1 = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID1, drops(1)), + token::expiration(expiration), + token::owner(minter)); + + env.close(); + BEAST_EXPECT(lastClose(env) < expiration); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 3); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Unexpired offers can be brokered. + env(token::brokerOffers(issuer, buyOffer0, sellOffer0)); + + // Close enough ledgers to get past the expiration. + while (lastClose(env) < expiration) + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // If the offers are expired they cannot be brokered. + env(token::brokerOffers(issuer, buyOffer1, sellOffer1), + ter(tecEXPIRED)); + env.close(); + + // The expired offers are still in the ledger. + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 2); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + + // Anyone can cancel the expired offers. + env(token::cancelOffer(issuer, {buyOffer1, sellOffer1})); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 1); + + // Transfer nftokenID0 back to minter so we start the next test in + // a simple place. + uint256 const offerSellBack = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftokenID0, XRP(0)), + txflags(tfSellNFToken), + token::destination(minter)); + env.close(); + env(token::acceptSellOffer(minter, offerSellBack)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 0); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 0); + } + } + + void + testCancelOffers(FeatureBitset features) + { + // Look at offer canceling. + testcase("Cancel offers"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const alice("alice"); + Account const becky("becky"); + Account const minter("minter"); + env.fund(XRP(50000), alice, becky, minter); + env.close(); + + // alice has a minter to see if minters have offer canceling permission. + env(token::setMinter(alice, minter)); + env.close(); + + uint256 const nftokenID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0), txflags(tfTransferable)); + env.close(); + + // Anyone can cancel an expired offer. + uint256 const expiredOfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + + env(token::createOffer(alice, nftokenID, XRP(1000)), + txflags(tfSellNFToken), + token::expiration(lastClose(env) + 13)); + env.close(); + + // The offer has not expired yet, so becky can't cancel it now. + BEAST_EXPECT(ownerCount(env, alice) == 2); + env(token::cancelOffer(becky, {expiredOfferIndex}), + ter(tecNO_PERMISSION)); + env.close(); + + // Close a couple of ledgers and advance the time. Then becky + // should be able to cancel the (now) expired offer. + env.close(); + env.close(); + env(token::cancelOffer(becky, {expiredOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // Create a couple of offers with a destination. Those offers + // should be cancellable by the creator and the destination. + uint256 const dest1OfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + + env(token::createOffer(alice, nftokenID, XRP(1000)), + token::destination(becky), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + // Minter can't cancel that offer, but becky (the destination) can. + env(token::cancelOffer(minter, {dest1OfferIndex}), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + env(token::cancelOffer(becky, {dest1OfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // alice can cancel her own offer, even if becky is the destination. + uint256 const dest2OfferIndex = + keylet::nftoffer(alice, env.seq(alice)).key; + + env(token::createOffer(alice, nftokenID, XRP(1000)), + token::destination(becky), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + env(token::cancelOffer(alice, {dest2OfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // The issuer has no special permissions regarding offer cancellation. + // Minter creates a token with alice as issuer. alice cannot cancel + // minter's offer. + uint256 const mintersNFTokenID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(minter, 0), + token::issuer(alice), + txflags(tfTransferable)); + env.close(); + + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + + env(token::createOffer(minter, mintersNFTokenID, XRP(1000)), + txflags(tfSellNFToken)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 2); + + // Nobody other than minter should be able to cancel minter's offer. + env(token::cancelOffer(alice, {minterOfferIndex}), + ter(tecNO_PERMISSION)); + env(token::cancelOffer(becky, {minterOfferIndex}), + ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 2); + + env(token::cancelOffer(minter, {minterOfferIndex})); + env.close(); + BEAST_EXPECT(ownerCount(env, minter) == 1); + } + + void + testCancelTooManyOffers(FeatureBitset features) + { + // Look at the case where too many offers are passed in a cancel. + testcase("Cancel too many offers"); + + using namespace test::jtx; + + Env env{*this, features}; + + // We want to maximize the metadata from a cancel offer transaction to + // make sure we don't hit metadata limits. The way we'll do that is: + // + // 1. Generate twice as many separate funded accounts as we have + // offers. + // 2. + // a. One of these accounts mints an NFT with a full URL. + // b. The other account makes an offer that will expire soon. + // 3. After all of these offers have expired, cancel all of the + // expired offers in a single transaction. + // + // I can't think of any way to increase the metadata beyond this, + // but I'm open to ideas. + Account const alice("alice"); + env.fund(XRP(1000), alice); + env.close(); + + std::string const uri(maxTokenURILength, '?'); + std::vector offerIndexes; + offerIndexes.reserve(maxTokenOfferCancelCount + 1); + for (uint32_t i = 0; i < maxTokenOfferCancelCount + 1; ++i) + { + Account const nftAcct(std::string("nftAcct") + std::to_string(i)); + Account const offerAcct( + std::string("offerAcct") + std::to_string(i)); + env.fund(XRP(1000), nftAcct, offerAcct); + env.close(); + + uint256 const nftokenID = + token::getNextID(env, nftAcct, 0, tfTransferable); + env(token::mint(nftAcct, 0), + token::uri(uri), + txflags(tfTransferable)); + env.close(); + + offerIndexes.push_back( + keylet::nftoffer(offerAcct, env.seq(offerAcct)).key); + env(token::createOffer(offerAcct, nftokenID, drops(1)), + token::owner(nftAcct), + token::expiration(lastClose(env) + 5)); + env.close(); + } + + // Close the ledger so the last of the offers expire. + env.close(); + + // All offers should be in the ledger. + for (uint256 const& offerIndex : offerIndexes) + { + BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex))); + } + + // alice attempts to cancel all of the expired offers. There is one + // too many so the request fails. + env(token::cancelOffer(alice, offerIndexes), ter(temMALFORMED)); + env.close(); + + // However alice can cancel just one of the offers. + env(token::cancelOffer(alice, {offerIndexes.back()})); + env.close(); + + // Verify that offer is gone from the ledger. + BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndexes.back()))); + offerIndexes.pop_back(); + + // But alice adds a sell offer to the list... + { + uint256 const nftokenID = + token::getNextID(env, alice, 0, tfTransferable); + env(token::mint(alice, 0), + token::uri(uri), + txflags(tfTransferable)); + env.close(); + + offerIndexes.push_back(keylet::nftoffer(alice, env.seq(alice)).key); + env(token::createOffer(alice, nftokenID, drops(1)), + txflags(tfSellNFToken)); + env.close(); + + // alice's owner count should now to 2 for the nft and the offer. + BEAST_EXPECT(ownerCount(env, alice) == 2); + + // Because alice added the sell offer there are still too many + // offers in the list to cancel. + env(token::cancelOffer(alice, offerIndexes), ter(temMALFORMED)); + env.close(); + + // alice burns her nft which removes the nft and the offer. + env(token::burn(alice, nftokenID)); + env.close(); + + // If alice's owner count is zero we can see that the offer + // and nft are both gone. + BEAST_EXPECT(ownerCount(env, alice) == 0); + offerIndexes.pop_back(); + } + + // Now there are few enough offers in the list that they can all + // be cancelled in a single transaction. + env(token::cancelOffer(alice, offerIndexes)); + env.close(); + + // Verify that remaining offers are gone from the ledger. + for (uint256 const& offerIndex : offerIndexes) + { + BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndex))); + } + } + + void + testBrokeredAccept(FeatureBitset features) + { + // Look at the case where too many offers are passed in a cancel. + testcase("Brokered NFT offer accept"); + + using namespace test::jtx; + + Env env{*this, features}; + + // The most important thing to explore here is the way funds are + // assigned from the buyer to... + // o the Seller, + // o the Broker, and + // o the Issuer (in the case of a transfer fee). + + Account const issuer{"issuer"}; + Account const minter{"minter"}; + Account const buyer{"buyer"}; + Account const broker{"broker"}; + Account const gw{"gw"}; + IOU const gwXAU(gw["XAU"]); + + env.fund(XRP(1000), issuer, minter, buyer, broker, gw); + env.close(); + + env(trust(issuer, gwXAU(2000))); + env(trust(minter, gwXAU(2000))); + env(trust(buyer, gwXAU(2000))); + env(trust(broker, gwXAU(2000))); + env.close(); + + env(token::setMinter(issuer, minter)); + env.close(); + + // Lambda to check owner count of all accounts is one. + auto checkOwnerCountIsOne = + [this, &env]( + std::initializer_list> + accounts, + int line) { + for (Account const& acct : accounts) + { + if (std::uint32_t ownerCount = this->ownerCount(env, acct); + ownerCount != 1) + { + std::stringstream ss; + ss << "Account " << acct.human() + << " expected ownerCount == 1. Got " << ownerCount; + fail(ss.str(), __FILE__, line); + } + } + }; + + // Lambda that mints an NFT and returns the nftID. + auto mintNFT = [&env, &issuer, &minter](std::uint16_t xferFee = 0) { + uint256 const nftID = + token::getNextID(env, issuer, 0, tfTransferable, xferFee); + env(token::mint(minter, 0), + token::issuer(issuer), + token::xferFee(xferFee), + txflags(tfTransferable)); + env.close(); + return nftID; + }; + + // o Seller is selling for zero XRP. + // o Broker charges no fee. + // o No transfer fee. + // + // Since minter is selling for zero the currency must be XRP. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + + // buyer creates their offer. Note: a buy offer can never + // offer zero. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, XRP(1)), token::owner(minter)); + env.close(); + + auto const minterBalance = env.balance(minter); + auto const buyerBalance = env.balance(buyer); + auto const brokerBalance = env.balance(broker); + auto const issuerBalance = env.balance(issuer); + + // Broker charges no brokerFee. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex)); + env.close(); + + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(1)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + + // o Seller is selling for zero XRP. + // o Broker charges a fee. + // o No transfer fee. + // + // Since minter is selling for zero the currency must be XRP. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + + // buyer creates their offer. Note: a buy offer can never + // offer zero. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, XRP(1)), token::owner(minter)); + env.close(); + + // Broker attempts to charge a 1.1 XRP brokerFee and fails. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(XRP(1.1)), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + auto const minterBalance = env.balance(minter); + auto const buyerBalance = env.balance(buyer); + auto const brokerBalance = env.balance(broker); + auto const issuerBalance = env.balance(issuer); + + // Broker charges a 0.5 XRP brokerFee. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(XRP(0.5))); + env.close(); + + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT( + env.balance(broker) == brokerBalance + XRP(0.5) - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + + // o Seller is selling for zero XRP. + // o Broker charges no fee. + // o 50% transfer fee. + // + // Since minter is selling for zero the currency must be XRP. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(maxTransferFee); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + + // buyer creates their offer. Note: a buy offer can never + // offer zero. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, XRP(1)), token::owner(minter)); + env.close(); + + auto const minterBalance = env.balance(minter); + auto const buyerBalance = env.balance(buyer); + auto const brokerBalance = env.balance(broker); + auto const issuerBalance = env.balance(issuer); + + // Broker charges no brokerFee. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex)); + env.close(); + + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.5)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + + // o Seller is selling for zero XRP. + // o Broker charges 0.5 XRP. + // o 50% transfer fee. + // + // Since minter is selling for zero the currency must be XRP. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(maxTransferFee); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, XRP(0)), + txflags(tfSellNFToken)); + env.close(); + + // buyer creates their offer. Note: a buy offer can never + // offer zero. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, XRP(1)), token::owner(minter)); + env.close(); + + auto const minterBalance = env.balance(minter); + auto const buyerBalance = env.balance(buyer); + auto const brokerBalance = env.balance(broker); + auto const issuerBalance = env.balance(issuer); + + // Broker charges a 0.75 XRP brokerFee. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(XRP(0.75))); + env.close(); + + // Note that, with a 50% transfer fee, issuer gets 1/2 of what's + // left _after_ broker takes their fee. minter gets the remainder + // after both broker and minter take their cuts + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.125)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT( + env.balance(broker) == brokerBalance + XRP(0.75) - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.125)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + + // Lambda to set the balance of all passed in accounts to gwXAU(1000). + auto setXAUBalance_1000 = + [this, &gw, &gwXAU, &env]( + std::initializer_list> + accounts, + int line) { + for (Account const& acct : accounts) + { + static const auto xau1000 = gwXAU(1000); + auto const balance = env.balance(acct, gwXAU); + if (balance < xau1000) + { + env(pay(gw, acct, xau1000 - balance)); + env.close(); + } + else if (balance > xau1000) + { + env(pay(acct, gw, balance - xau1000)); + env.close(); + } + if (env.balance(acct, gwXAU) != xau1000) + { + std::stringstream ss; + ss << "Unable to set " << acct.human() + << " account balance to gwXAU(1000)"; + this->fail(ss.str(), __FILE__, line); + } + } + }; + + // The buyer and seller have identical amounts and there is no + // transfer fee. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(1000)), + txflags(tfSellNFToken)); + env.close(); + + { + // buyer creates an offer for more XAU than they currently own. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(1001)), + token::owner(minter)); + env.close(); + + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Cancel buyer's bad offer so the next test starts in a + // clean state. + env(token::cancelOffer(buyer, {buyOfferIndex})); + env.close(); + } + { + // buyer creates an offer for less that what minter is asking. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(999)), + token::owner(minter)); + env.close(); + + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + // Cancel buyer's bad offer so the next test starts in a + // clean state. + env(token::cancelOffer(buyer, {buyOfferIndex})); + env.close(); + } + + // buyer creates a large enough offer. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(1000)), + token::owner(minter)); + env.close(); + + // Broker attempts to charge a brokerFee but cannot. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(0.1)), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + // broker charges no brokerFee and succeeds. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex)); + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + BEAST_EXPECT(ownerCount(env, broker) == 1); + BEAST_EXPECT(env.balance(issuer, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(2000)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0)); + BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(1000)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + + // seller offers more than buyer is asking. + // There are both transfer and broker fees. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(maxTransferFee); + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(900)), + txflags(tfSellNFToken)); + env.close(); + { + // buyer creates an offer for more XAU than they currently own. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(1001)), + token::owner(minter)); + env.close(); + + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Cancel buyer's bad offer so the next test starts in a + // clean state. + env(token::cancelOffer(buyer, {buyOfferIndex})); + env.close(); + } + { + // buyer creates an offer for less that what minter is asking. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(899)), + token::owner(minter)); + env.close(); + + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + // Cancel buyer's bad offer so the next test starts in a + // clean state. + env(token::cancelOffer(buyer, {buyOfferIndex})); + env.close(); + } + // buyer creates a large enough offer. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(1000)), + token::owner(minter)); + env.close(); + + // Broker attempts to charge a brokerFee larger than the + // difference between the two offers but cannot. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(101)), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + // broker charges the full difference between the two offers and + // succeeds. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(100))); + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + BEAST_EXPECT(ownerCount(env, broker) == 1); + BEAST_EXPECT(env.balance(issuer, gwXAU) == gwXAU(1450)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1450)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0)); + BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(1100)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + // seller offers more than buyer is asking. + // There are both transfer and broker fees, but broker takes less than + // the maximum. + { + checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); + + uint256 const nftID = mintNFT(maxTransferFee / 2); // 25% + + // minter creates their offer. + uint256 const minterOfferIndex = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, gwXAU(900)), + txflags(tfSellNFToken)); + env.close(); + + // buyer creates a large enough offer. + uint256 const buyOfferIndex = + keylet::nftoffer(buyer, env.seq(buyer)).key; + env(token::createOffer(buyer, nftID, gwXAU(1000)), + token::owner(minter)); + env.close(); + + // broker charges half difference between the two offers and + // succeeds. 25% of the remaining difference goes to issuer. + // The rest goes to minter. + env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(50))); + env.close(); + + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 1); + BEAST_EXPECT(ownerCount(env, buyer) == 2); + BEAST_EXPECT(ownerCount(env, broker) == 1); + BEAST_EXPECT(env.balance(issuer, gwXAU) == gwXAU(1237.5)); + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1712.5)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0)); + BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(1050)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } + } + + void + testNFTokenWithTickets(FeatureBitset features) + { + // Make sure all NFToken transactions work with tickets. + testcase("NFToken transactions with tickets"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const buyer{"buyer"}; + env.fund(XRP(10000), issuer, buyer); + env.close(); + + // issuer and buyer grab enough tickets for all of the following + // transactions. Note that once the tickets are acquired issuer's + // and buyer's account sequence numbers should not advance. + std::uint32_t issuerTicketSeq{env.seq(issuer) + 1}; + env(ticket::create(issuer, 10)); + env.close(); + std::uint32_t const issuerSeq{env.seq(issuer)}; + BEAST_EXPECT(ticketCount(env, issuer) == 10); + + std::uint32_t buyerTicketSeq{env.seq(buyer) + 1}; + env(ticket::create(buyer, 10)); + env.close(); + std::uint32_t const buyerSeq{env.seq(buyer)}; + BEAST_EXPECT(ticketCount(env, buyer) == 10); + + // NFTokenMint + BEAST_EXPECT(ownerCount(env, issuer) == 10); + uint256 const nftId{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(issuer, 0u), + txflags(tfTransferable), + ticket::use(issuerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 10); + BEAST_EXPECT(ticketCount(env, issuer) == 9); + + // NFTokenCreateOffer + BEAST_EXPECT(ownerCount(env, buyer) == 10); + uint256 const offerIndex0 = keylet::nftoffer(buyer, buyerTicketSeq).key; + env(token::createOffer(buyer, nftId, XRP(1)), + token::owner(issuer), + ticket::use(buyerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 10); + BEAST_EXPECT(ticketCount(env, buyer) == 9); + + // NFTokenCancelOffer + env(token::cancelOffer(buyer, {offerIndex0}), + ticket::use(buyerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 8); + BEAST_EXPECT(ticketCount(env, buyer) == 8); + + // NFTokenCreateOffer. buyer tries again. + uint256 const offerIndex1 = keylet::nftoffer(buyer, buyerTicketSeq).key; + env(token::createOffer(buyer, nftId, XRP(2)), + token::owner(issuer), + ticket::use(buyerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, buyer) == 8); + BEAST_EXPECT(ticketCount(env, buyer) == 7); + + // NFTokenAcceptOffer. issuer accepts buyer's offer. + env(token::acceptBuyOffer(issuer, offerIndex1), + ticket::use(issuerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 8); + BEAST_EXPECT(ownerCount(env, buyer) == 8); + BEAST_EXPECT(ticketCount(env, issuer) == 8); + + // NFTokenBurn. buyer burns the token they just bought. + env(token::burn(buyer, nftId), ticket::use(buyerTicketSeq++)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 8); + BEAST_EXPECT(ownerCount(env, buyer) == 6); + BEAST_EXPECT(ticketCount(env, buyer) == 6); + + // Verify that the account sequence numbers did not advance. + BEAST_EXPECT(env.seq(issuer) == issuerSeq); + BEAST_EXPECT(env.seq(buyer) == buyerSeq); + } + + void + testNFTokenDeleteAccount(FeatureBitset features) + { + // Account deletion rules with NFTs: + // 1. An account holding one or more NFT offers may be deleted. + // 2. An NFT issuer with any NFTs they have issued still in the + // ledger may not be deleted. + // 3. An account holding one or more NFTs may not be deleted. + testcase("NFToken delete account"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const minter{"minter"}; + Account const becky{"becky"}; + Account const carla{"carla"}; + Account const daria{"daria"}; + + env.fund(XRP(10000), issuer, minter, becky, carla, daria); + env.close(); + + // Allow enough ledgers to pass so any of these accounts can be deleted. + for (int i = 0; i < 300; ++i) + env.close(); + + env(token::setMinter(issuer, minter)); + env.close(); + + uint256 const nftId{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(minter, 0u), + token::issuer(issuer), + txflags(tfTransferable)); + env.close(); + + // At the momement issuer and minter cannot delete themselves. + // o issuer has an issued NFT in the ledger. + // o minter owns an NFT. + env(acctdelete(issuer, daria), fee(XRP(50)), ter(tecHAS_OBLIGATIONS)); + env(acctdelete(minter, daria), fee(XRP(50)), ter(tecHAS_OBLIGATIONS)); + env.close(); + + // Let enough ledgers pass so the account delete transactions are + // not retried. + for (int i = 0; i < 15; ++i) + env.close(); + + // becky and carla create offers for minter's NFT. + env(token::createOffer(becky, nftId, XRP(2)), token::owner(minter)); + env.close(); + + uint256 const carlaOfferIndex = + keylet::nftoffer(carla, env.seq(carla)).key; + env(token::createOffer(carla, nftId, XRP(3)), token::owner(minter)); + env.close(); + + // It should be possible for becky to delete herself, even though + // becky has an active NFT offer. + env(acctdelete(becky, daria), fee(XRP(50))); + env.close(); + + // minter accepts carla's offer. + env(token::acceptBuyOffer(minter, carlaOfferIndex)); + env.close(); + + // Now it should be possible for minter to delete themselves since + // they no longer own an NFT. + env(acctdelete(minter, daria), fee(XRP(50))); + env.close(); + + // 1. issuer cannot delete themselves because they issued an NFT that + // is still in the ledger. + // 2. carla owns an NFT, so she cannot delete herself. + env(acctdelete(issuer, daria), fee(XRP(50)), ter(tecHAS_OBLIGATIONS)); + env(acctdelete(carla, daria), fee(XRP(50)), ter(tecHAS_OBLIGATIONS)); + env.close(); + + // Let enough ledgers pass so the account delete transactions are + // not retried. + for (int i = 0; i < 15; ++i) + env.close(); + + // carla burns her NFT. Since issuer's NFT is no longer in the + // ledger, both issuer and carla can delete themselves. + env(token::burn(carla, nftId)); + env.close(); + + env(acctdelete(issuer, daria), fee(XRP(50))); + env(acctdelete(carla, daria), fee(XRP(50))); + env.close(); + } + + void + testWithFeats(FeatureBitset features) + { + testEnabled(features); + testMintReserve(features); + testMintMaxTokens(features); + testMintInvalid(features); + testBurnInvalid(features); + testCreateOfferInvalid(features); + testCancelOfferInvalid(features); + testAcceptOfferInvalid(features); + testMintFlagBurnable(features); + testMintFlagOnlyXRP(features); + testMintFlagCreateTrustLine(features); + testMintFlagTransferable(features); + testMintTransferFee(features); + testMintTaxon(features); + testMintURI(features); + testCreateOfferDestination(features); + testCreateOfferExpiration(features); + testCancelOffers(features); + testCancelTooManyOffers(features); + testBrokeredAccept(features); + testNFTokenWithTickets(features); + testNFTokenDeleteAccount(features); + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = supported_amendments(); + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(NFToken, tx, ripple, 2); + +} // namespace ripple diff --git a/src/test/jtx.h b/src/test/jtx.h index e1a0e4844a0..bcf51398d5c 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -59,6 +59,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/Account.h b/src/test/jtx/Account.h index b5a1a98eb10..1595d444354 100644 --- a/src/test/jtx/Account.h +++ b/src/test/jtx/Account.h @@ -73,6 +73,10 @@ class Account /** @} */ + enum AcctStringType { base58Seed, other }; + /** Create an account from a base58 seed string. Throws on invalid seed. */ + Account(AcctStringType stringType, std::string base58SeedStr); + /** Return the name */ std::string const& name() const @@ -132,7 +136,7 @@ class Account // Return the account from the cache & add it to the cache if needed static Account - fromCache(std::string name, KeyType type); + fromCache(AcctStringType stringType, std::string name, KeyType type); std::string name_; PublicKey pk_; diff --git a/src/test/jtx/impl/Account.cpp b/src/test/jtx/impl/Account.cpp index 3a3e095971f..a17186d4ffb 100644 --- a/src/test/jtx/impl/Account.cpp +++ b/src/test/jtx/impl/Account.cpp @@ -46,14 +46,25 @@ Account::Account( } Account -Account::fromCache(std::string name, KeyType type) +Account::fromCache(AcctStringType stringType, std::string name, KeyType type) { auto p = std::make_pair(name, type); // non-const so it can be moved from auto const iter = cache_.find(p); if (iter != cache_.end()) return iter->second; - auto const keys = generateKeyPair(type, generateSeed(name)); + auto const keys = [stringType, &name, type]() { + // Special handling for base58Seeds. + if (stringType == base58Seed) + { + std::optional const seed = parseBase58(name); + if (!seed.has_value()) + Throw("Account:: invalid base58 seed"); + + return generateKeyPair(type, *seed); + } + return generateKeyPair(type, generateSeed(name)); + }(); auto r = cache_.emplace( std::piecewise_construct, std::forward_as_tuple(std::move(p)), @@ -62,7 +73,15 @@ Account::fromCache(std::string name, KeyType type) } Account::Account(std::string name, KeyType type) - : Account(fromCache(std::move(name), type)) + : Account(fromCache(Account::other, std::move(name), type)) +{ +} + +Account::Account(AcctStringType stringType, std::string base58SeedStr) + : Account(fromCache( + Account::base58Seed, + std::move(base58SeedStr), + KeyType::secp256k1)) { } diff --git a/src/test/jtx/impl/offer.cpp b/src/test/jtx/impl/offer.cpp index 3df60d0e0fe..6e9a1b4f2ff 100644 --- a/src/test/jtx/impl/offer.cpp +++ b/src/test/jtx/impl/offer.cpp @@ -27,14 +27,14 @@ namespace jtx { Json::Value offer( Account const& account, - STAmount const& in, - STAmount const& out, + STAmount const& takerPays, + STAmount const& takerGets, std::uint32_t flags) { Json::Value jv; jv[jss::Account] = account.human(); - jv[jss::TakerPays] = in.getJson(JsonOptions::none); - jv[jss::TakerGets] = out.getJson(JsonOptions::none); + jv[jss::TakerPays] = takerPays.getJson(JsonOptions::none); + jv[jss::TakerGets] = takerGets.getJson(JsonOptions::none); if (flags) jv[jss::Flags] = flags; jv[jss::TransactionType] = jss::OfferCreate; diff --git a/src/test/jtx/impl/token.cpp b/src/test/jtx/impl/token.cpp new file mode 100644 index 00000000000..cfbcfe11c98 --- /dev/null +++ b/src/test/jtx/impl/token.cpp @@ -0,0 +1,223 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2012, 2013 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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 + +namespace ripple { +namespace test { +namespace jtx { +namespace token { + +Json::Value +mint(jtx::Account const& account, std::uint32_t nfTokenTaxon) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenTaxon.jsonName] = nfTokenTaxon; + jv[sfTransactionType.jsonName] = jss::NFTokenMint; + return jv; +} + +void +xferFee::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfTransferFee.jsonName] = xferFee_; +} + +void +issuer::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfIssuer.jsonName] = issuer_; +} + +void +uri::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfURI.jsonName] = uri_; +} + +uint256 +getNextID( + jtx::Env const& env, + jtx::Account const& issuer, + std::uint32_t nfTokenTaxon, + std::uint16_t flags, + std::uint16_t xferFee) +{ + // Get the nftSeq from the account root of the issuer. + std::uint32_t const nftSeq = { + env.le(issuer)->at(~sfMintedNFTokens).value_or(0)}; + return getID(issuer, nfTokenTaxon, nftSeq, flags, xferFee); +} + +uint256 +getID( + jtx::Account const& issuer, + std::uint32_t nfTokenTaxon, + std::uint32_t nftSeq, + std::uint16_t flags, + std::uint16_t xferFee) +{ + return ripple::NFTokenMint::createNFTokenID( + flags, xferFee, issuer, nft::toTaxon(nfTokenTaxon), nftSeq); +} + +Json::Value +burn(jtx::Account const& account, uint256 const& nftokenID) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenID.jsonName] = to_string(nftokenID); + jv[jss::TransactionType] = jss::NFTokenBurn; + return jv; +} + +Json::Value +createOffer( + jtx::Account const& account, + uint256 const& nftokenID, + STAmount const& amount) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenID.jsonName] = to_string(nftokenID); + jv[sfAmount.jsonName] = amount.getJson(JsonOptions::none); + jv[jss::TransactionType] = jss::NFTokenCreateOffer; + return jv; +} + +void +owner::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfOwner.jsonName] = owner_; +} + +void +expiration::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfExpiration.jsonName] = expires_; +} + +void +destination::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfDestination.jsonName] = dest_; +} + +template +static Json::Value +cancelOfferImpl(jtx::Account const& account, T const& nftokenOffers) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + if (!empty(nftokenOffers)) + { + jv[sfNFTokenOffers.jsonName] = Json::arrayValue; + for (uint256 const& nftokenOffer : nftokenOffers) + jv[sfNFTokenOffers.jsonName].append(to_string(nftokenOffer)); + } + jv[jss::TransactionType] = jss::NFTokenCancelOffer; + return jv; +} + +Json::Value +cancelOffer( + jtx::Account const& account, + std::initializer_list const& nftokenOffers) +{ + return cancelOfferImpl(account, nftokenOffers); +} + +Json::Value +cancelOffer( + jtx::Account const& account, + std::vector const& nftokenOffers) +{ + return cancelOfferImpl(account, nftokenOffers); +} + +void +rootIndex::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfRootIndex.jsonName] = rootIndex_; +} + +Json::Value +acceptBuyOffer(jtx::Account const& account, uint256 const& offerIndex) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenBuyOffer.jsonName] = to_string(offerIndex); + jv[jss::TransactionType] = jss::NFTokenAcceptOffer; + return jv; +} + +Json::Value +acceptSellOffer(jtx::Account const& account, uint256 const& offerIndex) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenSellOffer.jsonName] = to_string(offerIndex); + jv[jss::TransactionType] = jss::NFTokenAcceptOffer; + return jv; +} + +Json::Value +brokerOffers( + jtx::Account const& account, + uint256 const& buyOfferIndex, + uint256 const& sellOfferIndex) +{ + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfNFTokenBuyOffer.jsonName] = to_string(buyOfferIndex); + jv[sfNFTokenSellOffer.jsonName] = to_string(sellOfferIndex); + jv[jss::TransactionType] = jss::NFTokenAcceptOffer; + return jv; +} + +void +brokerFee::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfNFTokenBrokerFee.jsonName] = brokerFee_.getJson(JsonOptions::none); +} + +Json::Value +setMinter(jtx::Account const& account, jtx::Account const& minter) +{ + Json::Value jt = fset(account, asfAuthorizedNFTokenMinter); + jt[sfNFTokenMinter.fieldName] = minter.human(); + return jt; +} + +Json::Value +clearMinter(jtx::Account const& account) +{ + return fclear(account, asfAuthorizedNFTokenMinter); +} + +} // namespace token +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/offer.h b/src/test/jtx/offer.h index b194f301113..35a46d19b6b 100644 --- a/src/test/jtx/offer.h +++ b/src/test/jtx/offer.h @@ -32,8 +32,8 @@ namespace jtx { Json::Value offer( Account const& account, - STAmount const& in, - STAmount const& out, + STAmount const& takerPays, + STAmount const& takerGets, std::uint32_t flags = 0); /** Cancel an offer. */ diff --git a/src/test/jtx/token.h b/src/test/jtx/token.h new file mode 100644 index 00000000000..44f89087b85 --- /dev/null +++ b/src/test/jtx/token.h @@ -0,0 +1,231 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2021 Ripple Labs Inc. + + Permission to use, copy, modify, and/or 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. +*/ +//============================================================================== + +#ifndef RIPPLE_TEST_JTX_NFT_H_INCLUDED +#define RIPPLE_TEST_JTX_NFT_H_INCLUDED + +#include +#include +#include + +#include + +#include + +namespace ripple { +namespace test { +namespace jtx { + +namespace token { + +/** Mint an NFToken. */ +Json::Value +mint(jtx::Account const& account, std::uint32_t tokenTaxon = 0); + +/** Sets the optional TransferFee on an NFTokenMint. */ +class xferFee +{ +private: + std::uint16_t xferFee_; + +public: + explicit xferFee(std::uint16_t fee) : xferFee_(fee) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Sets the optional Issuer on an NFTokenMint. */ +class issuer +{ +private: + std::string issuer_; + +public: + explicit issuer(jtx::Account const& issue) : issuer_(issue.human()) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Sets the optional URI on an NFTokenMint. */ +class uri +{ +private: + std::string uri_; + +public: + explicit uri(std::string const& u) : uri_(strHex(u)) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Get the next NFTokenID that will be issued. */ +uint256 +getNextID( + jtx::Env const& env, + jtx::Account const& account, + std::uint32_t nftokenTaxon, + std::uint16_t flags = 0, + std::uint16_t xferFee = 0); + +/** Get the NFTokenID for a particular nftSequence. */ +uint256 +getID( + jtx::Account const& account, + std::uint32_t tokenTaxon, + std::uint32_t nftSeq, + std::uint16_t flags = 0, + std::uint16_t xferFee = 0); + +/** Burn an NFToken. */ +Json::Value +burn(jtx::Account const& account, uint256 const& nftokenID); + +/** Create an NFTokenOffer. */ +Json::Value +createOffer( + jtx::Account const& account, + uint256 const& nftokenID, + STAmount const& amount); + +/** Sets the optional Owner on an NFTokenOffer. */ +class owner +{ +private: + std::string owner_; + +public: + explicit owner(jtx::Account const& ownedBy) : owner_(ownedBy.human()) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Sets the optional Expiration field on an NFTokenOffer. */ +class expiration +{ +private: + std::uint32_t expires_; + +public: + explicit expiration(std::uint32_t const& expires) : expires_(expires) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Sets the optional Destination field on an NFTokenOffer. */ +class destination +{ +private: + std::string dest_; + +public: + explicit destination(jtx::Account const& dest) : dest_(dest.human()) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Cancel NFTokenOffers. */ +Json::Value +cancelOffer( + jtx::Account const& account, + std::initializer_list const& nftokenOffers = {}); + +Json::Value +cancelOffer( + jtx::Account const& account, + std::vector const& nftokenOffers); + +/** Sets the optional RootIndex field when canceling NFTokenOffers. */ +class rootIndex +{ +private: + std::string rootIndex_; + +public: + explicit rootIndex(uint256 const& index) : rootIndex_(to_string(index)) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Accept an NFToken buy offer. */ +Json::Value +acceptBuyOffer(jtx::Account const& account, uint256 const& offerIndex); + +/** Accept an NFToken sell offer. */ +Json::Value +acceptSellOffer(jtx::Account const& account, uint256 const& offerIndex); + +/** Broker two NFToken offers. */ +Json::Value +brokerOffers( + jtx::Account const& account, + uint256 const& buyOfferIndex, + uint256 const& sellOfferIndex); + +/** Sets the optional NFTokenBrokerFee field in a brokerOffer transaction. */ +class brokerFee +{ +private: + STAmount const brokerFee_; + +public: + explicit brokerFee(STAmount const fee) : brokerFee_(fee) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +/** Set the authorized minter on an account root. */ +Json::Value +setMinter(jtx::Account const& account, jtx::Account const& minter); + +/** Clear any authorized minter from an account root. */ +Json::Value +clearMinter(jtx::Account const& account); + +} // namespace token + +} // namespace jtx + +} // namespace test +} // namespace ripple + +#endif // RIPPLE_TEST_JTX_NFT_H_INCLUDED diff --git a/src/test/protocol/Hooks_test.cpp b/src/test/protocol/Hooks_test.cpp index 161404195a4..1f71abb3af7 100644 --- a/src/test/protocol/Hooks_test.cpp +++ b/src/test/protocol/Hooks_test.cpp @@ -133,7 +133,7 @@ class Hooks_test : public beast::unit_test::suite break; } - case STI_HASH256: { + case STI_UINT256: { uint256 u = uint256::fromVoid( "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBE" "EFDEADBEEF"); diff --git a/src/test/protocol/KnownFormatToGRPC_test.cpp b/src/test/protocol/KnownFormatToGRPC_test.cpp index 72ee6c8937e..bf49f2e3134 100644 --- a/src/test/protocol/KnownFormatToGRPC_test.cpp +++ b/src/test/protocol/KnownFormatToGRPC_test.cpp @@ -235,72 +235,77 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite // We'll be running through two sets of pbuf::Descriptors: the ones in // the OneOf and the common fields. Here is a lambda that factors out // the common checking code for these two cases. - auto checkFieldDesc = - [this, &sFields, &knownFormatName]( - pbuf::FieldDescriptor const* const fieldDesc) { - // gRPC has different handling for repeated vs non-repeated - // types. So we need to do that too. - std::string name; - if (fieldDesc->is_repeated()) - { - // Repeated-type handling. - - // Munge the fieldDescriptor name so it looks like the - // name in sFields. - name = fieldDesc->camelcase_name(); - name[0] = toupper(name[0]); + auto checkFieldDesc = [this, &sFields, &knownFormatName]( + pbuf::FieldDescriptor const* const + fieldDesc) { + // gRPC has different handling for repeated vs non-repeated + // types. So we need to do that too. + std::string name; + if (fieldDesc->is_repeated()) + { + // Repeated-type handling. - // The ledger gives UNL all caps. Adapt to that. - if (size_t const i = name.find("Unl"); - i != std::string::npos) - { - name[i + 1] = 'N'; - name[i + 2] = 'L'; - } + // Munge the fieldDescriptor name so it looks like the + // name in sFields. + name = fieldDesc->camelcase_name(); + name[0] = toupper(name[0]); - if (!sFields.count(name)) - { - fail( - std::string("Repeated Protobuf Descriptor '") + - name + "' expected in KnownFormat '" + - knownFormatName + "' and not found", - __FILE__, - __LINE__); - return; - } - pass(); + // The ledger gives UNL all caps. Adapt to that. + if (size_t const i = name.find("Unl"); i != std::string::npos) + { + name[i + 1] = 'N'; + name[i + 2] = 'L'; + } - validateRepeatedField(fieldDesc, sFields.at(name)); + // The ledger gives the NFT part of NFToken all caps. + // Adapt to that. + if (size_t const i = name.find("Nft"); i != std::string::npos) + { + name[i + 1] = 'F'; + name[i + 2] = 'T'; } - else + + if (!sFields.count(name)) { - // Non-repeated handling. - pbuf::Descriptor const* const entryDesc = - fieldDesc->message_type(); - if (entryDesc == nullptr) - return; + fail( + std::string("Repeated Protobuf Descriptor '") + name + + "' expected in KnownFormat '" + knownFormatName + + "' and not found", + __FILE__, + __LINE__); + return; + } + pass(); - name = entryDesc->name(); - if (!sFields.count(name)) - { - fail( - std::string("Protobuf Descriptor '") + - entryDesc->name() + - "' expected in KnownFormat '" + - knownFormatName + "' and not found", - __FILE__, - __LINE__); - return; - } - pass(); + validateRepeatedField(fieldDesc, sFields.at(name)); + } + else + { + // Non-repeated handling. + pbuf::Descriptor const* const entryDesc = + fieldDesc->message_type(); + if (entryDesc == nullptr) + return; - validateDescriptor( - entryDesc, sFields.at(entryDesc->name())); + name = entryDesc->name(); + if (!sFields.count(name)) + { + fail( + std::string("Protobuf Descriptor '") + + entryDesc->name() + "' expected in KnownFormat '" + + knownFormatName + "' and not found", + __FILE__, + __LINE__); + return; } - // Remove the validated field from the map so we can tell if - // there are left over fields at the end of all comparisons. - sFields.erase(name); - }; + pass(); + + validateDescriptor(entryDesc, sFields.at(entryDesc->name())); + } + // Remove the validated field from the map so we can tell if + // there are left over fields at the end of all comparisons. + sFields.erase(name); + }; // Compare the SFields to the FieldDescriptor->Descriptors. for (int i = 0; i < pbufDescriptor->field_count(); ++i) @@ -453,7 +458,7 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite // clang-format off static const std::array specialEntries{ SpecialEntry{ - "Currency", STI_HASH160, + "Currency", STI_UINT160, { {"name", fieldTYPE_STRING}, {"code", fieldTYPE_BYTES} @@ -581,9 +586,9 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite {STI_ACCOUNT, fieldTYPE_STRING}, {STI_AMOUNT, fieldTYPE_BYTES}, - {STI_HASH128, fieldTYPE_BYTES}, - {STI_HASH160, fieldTYPE_BYTES}, - {STI_HASH256, fieldTYPE_BYTES}, + {STI_UINT128, fieldTYPE_BYTES}, + {STI_UINT160, fieldTYPE_BYTES}, + {STI_UINT256, fieldTYPE_BYTES}, {STI_VL, fieldTYPE_BYTES}, }; //clang-format on @@ -601,7 +606,8 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite static const std::map sFieldCodeToFieldDescType{ {sfDomain.fieldCode, fieldTYPE_STRING}, - {sfFee.fieldCode, fieldTYPE_UINT64}}; + {sfFee.fieldCode, fieldTYPE_UINT64}, + {sfURI.fieldCode, fieldTYPE_STRING}}; if (auto const iter = sFieldCodeToFieldDescType.find(sField->fieldCode); iter != sFieldCodeToFieldDescType.end() && @@ -703,7 +709,9 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite // The following repeated types provide no further structure for their // in-ledger representation. We just have to trust that the gRPC // representation is reasonable for what the ledger implements. - static const std::set noFurtherDetail{{sfPaths.getName()}}; + static const std::set noFurtherDetail{ + {sfPaths.getName()}, + }; if (noFurtherDetail.count(sField->getName())) { @@ -721,8 +729,10 @@ class KnownFormatToGRPC_test : public beast::unit_test::suite {sfIndexes.getName(), &sfLedgerIndex}, {sfMajorities.getName(), &sfMajority}, {sfMemos.getName(), &sfMemo}, + {sfNFTokens.getName(), &sfNFToken}, {sfSignerEntries.getName(), &sfSignerEntry}, - {sfSigners.getName(), &sfSigner}}; + {sfSigners.getName(), &sfSigner}, + {sfNFTokenOffers.getName(), &sfLedgerIndex}}; if (!repeatsWhat.count(sField->getName())) { diff --git a/src/test/protocol/STObject_test.cpp b/src/test/protocol/STObject_test.cpp index c165aafd104..d89916eddf7 100644 --- a/src/test/protocol/STObject_test.cpp +++ b/src/test/protocol/STObject_test.cpp @@ -257,7 +257,7 @@ class STObject_test : public beast::unit_test::suite BEAST_EXPECT(shouldBeInvalid == sfInvalid); }; testInvalid(STI_VL, 255); - testInvalid(STI_HASH256, 255); + testInvalid(STI_UINT256, 255); testInvalid(STI_UINT32, 255); testInvalid(STI_VECTOR256, 255); testInvalid(STI_OBJECT, 255); diff --git a/src/test/rpc/AccountSet_test.cpp b/src/test/rpc/AccountSet_test.cpp index a125f318c41..8e1ec790b12 100644 --- a/src/test/rpc/AccountSet_test.cpp +++ b/src/test/rpc/AccountSet_test.cpp @@ -75,6 +75,13 @@ class AccountSet_test : public beast::unit_test::suite // elsewhere. continue; } + if (flag == asfAuthorizedNFTokenMinter) + { + // The asfAuthorizedNFTokenMinter flag requires the + // presence or absence of the sfNFTokenMinter field in + // the transaction. It is tested elsewhere. + continue; + } else if ( std::find(goodFlags.begin(), goodFlags.end(), flag) != goodFlags.end()) @@ -398,6 +405,18 @@ class AccountSet_test : public beast::unit_test::suite env(rate(gw, 2.0)); env.close(); + // Because we're hacking the ledger we need the account to have + // non-zero sfMintedNFTokens and sfBurnedNFTokens fields. This + // prevents an exception when the AccountRoot template is applied. + { + uint256 const nftId0{token::getNextID(env, gw, 0u)}; + env(token::mint(gw, 0u)); + env.close(); + + env(token::burn(gw, nftId0)); + env.close(); + } + // Note that we're bypassing almost all of the ledger's safety // checks with this modify() call. If you call close() between // here and the end of the test all the effort will be lost. diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index fdcefbf66c2..1692b980673 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -411,7 +411,7 @@ class LedgerRPC_test : public beast::unit_test::suite jvParams[jss::ledger_hash] = ledgerHash; Json::Value const jrr = env.rpc( "json", "ledger_entry", to_string(jvParams))[jss::result]; - checkErrorValue(jrr, "malformedRequest", ""); + checkErrorValue(jrr, "unexpectedLedgerType", ""); } } @@ -1170,7 +1170,7 @@ class LedgerRPC_test : public beast::unit_test::suite jvParams[jss::ledger_hash] = ledgerHash; Json::Value const jrr = env.rpc( "json", "ledger_entry", to_string(jvParams))[jss::result]; - checkErrorValue(jrr, "malformedRequest", ""); + checkErrorValue(jrr, "unexpectedLedgerType", ""); } { // Malformed account entry.