From 8aa617d97213bb346a0d29741396a299f9453cd1 Mon Sep 17 00:00:00 2001 From: Michael Legleux Date: Mon, 4 Apr 2022 11:31:26 -0700 Subject: [PATCH 1/8] Fix failing Clio build --- Builds/CMake/deps/Nudb.cmake | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Builds/CMake/deps/Nudb.cmake b/Builds/CMake/deps/Nudb.cmake index 73ab58898..9698d3f06 100644 --- a/Builds/CMake/deps/Nudb.cmake +++ b/Builds/CMake/deps/Nudb.cmake @@ -18,14 +18,14 @@ if (is_root_project) # NuDB not needed in the case of xrpl_core inclusion build message (STATUS "Pausing to download NuDB...") FetchContent_Populate(nudb_src) endif() -endif () -file(TO_CMAKE_PATH "${nudb_src_SOURCE_DIR}" nudb_src_SOURCE_DIR) -# specify as system includes so as to avoid warnings -target_include_directories (nudb SYSTEM INTERFACE ${nudb_src_SOURCE_DIR}/include) -target_link_libraries (nudb - INTERFACE - Boost::thread - Boost::system) -add_library (NIH::nudb ALIAS nudb) -target_link_libraries (ripple_libs INTERFACE NIH::nudb) + file(TO_CMAKE_PATH "${nudb_src_SOURCE_DIR}" nudb_src_SOURCE_DIR) + # specify as system includes so as to avoid warnings + target_include_directories (nudb SYSTEM INTERFACE ${nudb_src_SOURCE_DIR}/include) + target_link_libraries (nudb + INTERFACE + Boost::thread + Boost::system) + add_library (NIH::nudb ALIAS nudb) + target_link_libraries (ripple_libs INTERFACE NIH::nudb) +endif () From 656e9fe180e46b591df05d3e6c95a56660d2bb64 Mon Sep 17 00:00:00 2001 From: Michael Legleux Date: Sat, 2 Apr 2022 14:15:47 -0700 Subject: [PATCH 2/8] Upload rippled-reporting packages to Artifactory --- Builds/containers/gitlab-ci/push_to_artifactory.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Builds/containers/gitlab-ci/push_to_artifactory.sh b/Builds/containers/gitlab-ci/push_to_artifactory.sh index 847c2bc14..fc6056705 100644 --- a/Builds/containers/gitlab-ci/push_to_artifactory.sh +++ b/Builds/containers/gitlab-ci/push_to_artifactory.sh @@ -15,7 +15,9 @@ cd build/dpkg/packages CURLARGS="-sk -X${action} -urippled:${ARTIFACTORY_DEPLOY_KEY_RIPPLED}" RIPPLED_PKG=$(ls rippled_*.deb) RIPPLED_DEV_PKG=$(ls rippled-dev_*.deb) +RIPPLED_REPORTING_PKG=$(ls rippled-reporting_*.deb) RIPPLED_DBG_PKG=$(ls rippled-dbgsym_*.deb) +RIPPLED_REPORTING_DBG_PKG=$(ls rippled-reporting-dbgsym_*.deb) # TODO - where to upload src tgz? RIPPLED_SRC=$(ls rippled_*.orig.tar.gz) DEB_MATRIX=";deb.component=${COMPONENT};deb.architecture=amd64" @@ -23,7 +25,7 @@ for dist in stretch buster bullseye bionic focal jammy; do DEB_MATRIX="${DEB_MATRIX};deb.distribution=${dist}" done echo "{ \"debs\": {" > "${TOPDIR}/files.info" -for deb in ${RIPPLED_PKG} ${RIPPLED_DEV_PKG} ${RIPPLED_DBG_PKG} ; do +for deb in ${RIPPLED_PKG} ${RIPPLED_DEV_PKG} ${RIPPLED_DBG_PKG} ${RIPPLED_REPORTING_PKG} ${RIPPLED_REPORTING_DBG_PKG}; do # first item doesn't get a comma separator if [ $deb != $RIPPLED_PKG ] ; then echo "," >> "${TOPDIR}/files.info" @@ -48,10 +50,11 @@ cd build/rpm/packages RIPPLED_PKG=$(ls rippled-[0-9]*.x86_64.rpm) RIPPLED_DEV_PKG=$(ls rippled-devel*.rpm) RIPPLED_DBG_PKG=$(ls rippled-debuginfo*.rpm) +RIPPLED_REPORTING_PKG=$(ls rippled-reporting*.rpm) # TODO - where to upload src rpm ? RIPPLED_SRC=$(ls rippled-[0-9]*.src.rpm) echo "\"rpms\": {" >> "${TOPDIR}/files.info" -for rpm in ${RIPPLED_PKG} ${RIPPLED_DEV_PKG} ${RIPPLED_DBG_PKG} ; do +for rpm in ${RIPPLED_PKG} ${RIPPLED_DEV_PKG} ${RIPPLED_DBG_PKG} ${RIPPLED_REPORTING_PKG}; do # first item doesn't get a comma separator if [ $rpm != $RIPPLED_PKG ] ; then echo "," >> "${TOPDIR}/files.info" From 9d3cd718e4e7853963f8734c4f571eb9754683fa Mon Sep 17 00:00:00 2001 From: Michael Legleux Date: Sat, 2 Apr 2022 14:06:14 -0700 Subject: [PATCH 3/8] Do not install coreutils-single on rocky build image --- Builds/containers/gitlab-ci/smoketest.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Builds/containers/gitlab-ci/smoketest.sh b/Builds/containers/gitlab-ci/smoketest.sh index b233e6959..267c0aa7b 100644 --- a/Builds/containers/gitlab-ci/smoketest.sh +++ b/Builds/containers/gitlab-ci/smoketest.sh @@ -61,7 +61,11 @@ if [ "${pkgtype}" = "dpkg" ] ; then else yum -y update if [ "${install_from}" = "repo" ] ; then - yum -y install yum-utils coreutils util-linux + pkgs=("yum-utils coreutils util-linux") + if [ "$ID" = "rocky" ]; then + pkgs="${pkgs[@]/coreutils}" + fi + yum install -y $pkgs REPOFILE="/etc/yum.repos.d/artifactory.repo" echo "[Artifactory]" > ${REPOFILE} echo "name=Artifactory" >> ${REPOFILE} From 525aaecbcad68e09aede66f0858ee8a49474213c Mon Sep 17 00:00:00 2001 From: greg7mdp Date: Wed, 6 Apr 2022 15:17:47 -0400 Subject: [PATCH 4/8] Fix build issue on M1 macs --- src/ripple/shamap/impl/SHAMapInnerNode.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ripple/shamap/impl/SHAMapInnerNode.cpp b/src/ripple/shamap/impl/SHAMapInnerNode.cpp index c47ac3864..eb00f8587 100644 --- a/src/ripple/shamap/impl/SHAMapInnerNode.cpp +++ b/src/ripple/shamap/impl/SHAMapInnerNode.cpp @@ -34,8 +34,10 @@ #include #include +#ifndef __aarch64__ // This is used for the _mm_pause instruction: #include +#endif namespace ripple { @@ -100,7 +102,9 @@ public: if (try_lock()) return; +#ifndef __aarch64__ _mm_pause(); +#endif } std::this_thread::yield(); From 70779f6850b5f33cdbb9cf4129bc1c259af0013e Mon Sep 17 00:00:00 2001 From: Nik Bougalis Date: Fri, 13 Aug 2021 20:41:11 -0700 Subject: [PATCH 5/8] Introduce NFT support (XLS020) --- Builds/CMake/RippledCore.cmake | 11 + src/ripple/app/misc/NetworkOPs.cpp | 1 + src/ripple/app/tx/impl/CancelOffer.cpp | 3 +- src/ripple/app/tx/impl/CashCheck.cpp | 19 +- src/ripple/app/tx/impl/Change.cpp | 2 +- src/ripple/app/tx/impl/CreateCheck.cpp | 17 +- src/ripple/app/tx/impl/CreateOffer.cpp | 19 +- src/ripple/app/tx/impl/DeleteAccount.cpp | 46 +- src/ripple/app/tx/impl/DeleteAccount.h | 8 - src/ripple/app/tx/impl/DepositPreauth.cpp | 3 +- src/ripple/app/tx/impl/Escrow.cpp | 13 +- src/ripple/app/tx/impl/InvariantCheck.cpp | 189 + src/ripple/app/tx/impl/InvariantCheck.h | 49 +- src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp | 369 ++ src/ripple/app/tx/impl/NFTokenAcceptOffer.h | 60 + src/ripple/app/tx/impl/NFTokenBurn.cpp | 132 + src/ripple/app/tx/impl/NFTokenBurn.h | 48 + src/ripple/app/tx/impl/NFTokenCancelOffer.cpp | 115 + src/ripple/app/tx/impl/NFTokenCancelOffer.h | 48 + src/ripple/app/tx/impl/NFTokenCreateOffer.cpp | 234 + src/ripple/app/tx/impl/NFTokenCreateOffer.h | 48 + src/ripple/app/tx/impl/NFTokenMint.cpp | 211 + src/ripple/app/tx/impl/NFTokenMint.h | 58 + src/ripple/app/tx/impl/PayChan.cpp | 9 +- src/ripple/app/tx/impl/Payment.cpp | 3 +- src/ripple/app/tx/impl/SetAccount.cpp | 34 +- src/ripple/app/tx/impl/SetAccount.h | 2 - src/ripple/app/tx/impl/SetRegularKey.cpp | 3 +- src/ripple/app/tx/impl/SetSignerList.cpp | 3 +- src/ripple/app/tx/impl/SetTrust.cpp | 3 +- src/ripple/app/tx/impl/Transactor.cpp | 49 +- src/ripple/app/tx/impl/applySteps.cpp | 55 + .../app/tx/impl/details/NFTokenUtils.cpp | 544 +++ src/ripple/app/tx/impl/details/NFTokenUtils.h | 186 + src/ripple/basics/algorithm.h | 1 + src/ripple/basics/base_uint.h | 16 +- src/ripple/consensus/LedgerTrie.h | 1 - src/ripple/ledger/ApplyView.h | 6 + src/ripple/ledger/View.h | 62 +- src/ripple/ledger/impl/ApplyView.cpp | 25 + src/ripple/ledger/impl/View.cpp | 84 +- src/ripple/net/impl/RPCCall.cpp | 1 + src/ripple/proto/org/xrpl/rpc/v1/common.proto | 83 + .../org/xrpl/rpc/v1/ledger_objects.proto | 62 +- .../proto/org/xrpl/rpc/v1/transaction.proto | 66 +- src/ripple/protocol/ErrorCodes.h | 5 +- src/ripple/protocol/Feature.h | 3 +- src/ripple/protocol/Indexes.h | 40 +- src/ripple/protocol/LedgerFormats.h | 19 + src/ripple/protocol/Protocol.h | 41 +- src/ripple/protocol/Rate.h | 7 + src/ripple/protocol/SField.h | 141 +- src/ripple/protocol/STArray.h | 63 + src/ripple/protocol/STBitString.h | 18 +- src/ripple/protocol/STObject.h | 47 +- src/ripple/protocol/TER.h | 14 +- src/ripple/protocol/TxFlags.h | 140 +- src/ripple/protocol/TxFormats.h | 15 + src/ripple/protocol/impl/ErrorCodes.cpp | 151 +- src/ripple/protocol/impl/Feature.cpp | 1 + src/ripple/protocol/impl/Indexes.cpp | 49 +- .../protocol/impl/InnerObjectFormats.cpp | 7 + src/ripple/protocol/impl/LedgerFormats.cpp | 268 +- src/ripple/protocol/impl/Rate2.cpp | 9 + src/ripple/protocol/impl/SField.cpp | 87 +- src/ripple/protocol/impl/STObject.cpp | 10 +- src/ripple/protocol/impl/STParsedJSON.cpp | 15 +- src/ripple/protocol/impl/STVar.cpp | 24 +- src/ripple/protocol/impl/TER.cpp | 201 +- src/ripple/protocol/impl/TxFormats.cpp | 51 + src/ripple/protocol/jss.h | 45 +- src/ripple/protocol/nftPageMask.h | 37 + src/ripple/rpc/handlers/AccountObjects.cpp | 98 + src/ripple/rpc/handlers/Handlers.h | 6 + src/ripple/rpc/handlers/LedgerEntry.cpp | 33 +- src/ripple/rpc/handlers/NFTOffers.cpp | 180 + src/ripple/rpc/impl/GRPCHelpers.cpp | 267 +- src/ripple/rpc/impl/Handler.cpp | 3 + src/ripple/rpc/impl/RPCHelpers.cpp | 5 +- src/ripple/rpc/impl/Tuning.h | 6 + src/ripple/shamap/SHAMap.h | 15 +- src/ripple/shamap/impl/SHAMap.cpp | 2 - src/test/app/NFTokenBurn_test.cpp | 605 +++ src/test/app/NFTokenDir_test.cpp | 468 ++ src/test/app/NFToken_test.cpp | 4290 +++++++++++++++++ src/test/jtx.h | 1 + src/test/jtx/Account.h | 6 +- src/test/jtx/impl/Account.cpp | 25 +- src/test/jtx/impl/offer.cpp | 8 +- src/test/jtx/impl/token.cpp | 223 + src/test/jtx/offer.h | 4 +- src/test/jtx/token.h | 231 + src/test/protocol/Hooks_test.cpp | 2 +- src/test/protocol/KnownFormatToGRPC_test.cpp | 148 +- src/test/protocol/STObject_test.cpp | 2 +- src/test/rpc/AccountSet_test.cpp | 19 + src/test/rpc/LedgerRPC_test.cpp | 4 +- 97 files changed, 10386 insertions(+), 774 deletions(-) create mode 100644 src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp create mode 100644 src/ripple/app/tx/impl/NFTokenAcceptOffer.h create mode 100644 src/ripple/app/tx/impl/NFTokenBurn.cpp create mode 100644 src/ripple/app/tx/impl/NFTokenBurn.h create mode 100644 src/ripple/app/tx/impl/NFTokenCancelOffer.cpp create mode 100644 src/ripple/app/tx/impl/NFTokenCancelOffer.h create mode 100644 src/ripple/app/tx/impl/NFTokenCreateOffer.cpp create mode 100644 src/ripple/app/tx/impl/NFTokenCreateOffer.h create mode 100644 src/ripple/app/tx/impl/NFTokenMint.cpp create mode 100644 src/ripple/app/tx/impl/NFTokenMint.h create mode 100644 src/ripple/app/tx/impl/details/NFTokenUtils.cpp create mode 100644 src/ripple/app/tx/impl/details/NFTokenUtils.h create mode 100644 src/ripple/protocol/nftPageMask.h create mode 100644 src/ripple/rpc/handlers/NFTOffers.cpp create mode 100644 src/test/app/NFTokenBurn_test.cpp create mode 100644 src/test/app/NFTokenDir_test.cpp create mode 100644 src/test/app/NFToken_test.cpp create mode 100644 src/test/jtx/impl/token.cpp create mode 100644 src/test/jtx/token.h diff --git a/Builds/CMake/RippledCore.cmake b/Builds/CMake/RippledCore.cmake index c01077a5a..35b147ad4 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 e5dd5765d..4b44cf431 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 89d6b62c7..95d51501c 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 9a602d50e..b258ae7d9 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; } } - { - 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; - } + if (hasExpired(ctx.view, sleCheck->at(~sfExpiration))) + { + 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 8c34f532d..bd66d7d58 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 da5a70e64..a59a7c12e 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 833d18d88..4ec41f2b3 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 16d7eb205..da2244bca 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 c01991ca7..b0dbaa5bc 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 f49490cf8..7d99e6301 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 bafa6da04..7486dfaca 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 73b20a0f1..82f4cea6b 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 4398a31d1..5936b59b6 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.h +++ b/src/ripple/app/tx/impl/InvariantCheck.h @@ -318,6 +318,51 @@ public: 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 000000000..b7997996e --- /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 000000000..2d1b14ba2 --- /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 000000000..f1f5ae8a7 --- /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 000000000..61079c4a4 --- /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 000000000..50199ace8 --- /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 000000000..752d33ac8 --- /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 000000000..bf92472e2 --- /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 000000000..676b546f4 --- /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 000000000..b4e391c3e --- /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 000000000..690843c19 --- /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 869b1f472..aab3dcc5a 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 50045da8d..ccb0f1935 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 c965457fd..85fe290ca 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 cb37c6ecd..1c6bb4b7d 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 aee973a1c..1b5a3eede 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 8e321c4c1..78409ba71 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 5c60f4ed2..5f268f2c2 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 619ce031b..9265d3656 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 c70ac96d7..581a700cf 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 000000000..f99c6cf6b --- /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 000000000..aac5dbf5f --- /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 673d5e955..ed6e8080d 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 00b38eec7..ccbb24a13 100644 --- a/src/ripple/basics/base_uint.h +++ b/src/ripple/basics/base_uint.h @@ -332,7 +332,7 @@ public: return *this == beast::zero; } - const base_uint + constexpr base_uint operator~() const { base_uint ret; @@ -437,6 +437,20 @@ public: 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 108da0a30..0bb902ef1 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 5394acc78..a37ba6c46 100644 --- a/src/ripple/ledger/ApplyView.h +++ b/src/ripple/ledger/ApplyView.h @@ -355,6 +355,12 @@ public: } /** @} */ + /** 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 737fdee38..ee9171155 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,27 +118,55 @@ 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, - AccountID const& id, - std::function const&)> f); + Keylet const& root, + std::function const&)> const& f); -/** Iterate all items after an item in an owner directory. +/** 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&)> 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 + @param hint The directory page containing `after` + @param limit The maximum number of items to return + @return `false` if the iteration failed +*/ +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 5c550b67b..eced521fb 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 e7b033432..54e78ecc9 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 a8d72eda2..820f25ddf 100644 --- a/src/ripple/net/impl/RPCCall.cpp +++ b/src/ripple/net/impl/RPCCall.cpp @@ -1238,6 +1238,7 @@ public: {"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 5eb4cc8c8..81718b507 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 a26663456..d6db469a2 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 081f22e9e..05300422b 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 45fa7da29..98a8cf43a 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 d65e8f8f0..9087bec99 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 411f4db24..f7f35355e 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 1f6079838..2dd04b126 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 12609bef1..5df24271f 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 a982596e5..3524eabb6 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 28da73436..5039e4e05 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 @@ public: 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 @@ public: 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 8a38928cb..9501307c2 100644 --- a/src/ripple/protocol/STArray.h +++ b/src/ripple/protocol/STArray.h @@ -33,12 +33,28 @@ private: 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 @@ public: 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 @@ private: 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 2e242a4b2..1819d54d1 100644 --- a/src/ripple/protocol/STBitString.h +++ b/src/ripple/protocol/STBitString.h @@ -75,9 +75,9 @@ private: 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 97bc2b4e5..66c164185 100644 --- a/src/ripple/protocol/STObject.h +++ b/src/ripple/protocol/STObject.h @@ -80,6 +80,14 @@ public: 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 3a1351058..38342f0c1 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 9b75b692a..0b907c722 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 44f17fde2..250c29d69 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 87fb2da2a..e4a9acf46 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 e1d82cb1b..d713dc8c4 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 6d7b7cc22..69e7cc55d 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 32d712b95..c1b2acc87 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 b6feb3823..7d5cf9d21 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 3ca399427..340b6719b 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 679248dea..73098319b 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 0d5500738..f2d5fdfe3 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 b1ea05837..6473f80de 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 6d0442008..0628c95da 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 cb8cd7e89..c660b1cea 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(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(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 ff3f7f507..ce0d5db92 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 bd9edd02e..0dc413e6d 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 000000000..a4890b460 --- /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 e2a9b86e0..55fe4e413 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 264d0a3f1..1bb3be056 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 12696abb3..4b2526698 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 000000000..34bbc8446 --- /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 e6a0100e9..558c9d535 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 3613bf723..15f2ea8f8 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 c471d2b35..499f12323 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 233e73794..4f4a8be1b 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 1d221179c..2f0a677f9 100644 --- a/src/ripple/shamap/SHAMap.h +++ b/src/ripple/shamap/SHAMap.h @@ -210,14 +210,21 @@ public: 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 27547aaec..6f6acb9a7 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 000000000..e40f4c839 --- /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 000000000..c19a8d079 --- /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 000000000..40dfe2fe3 --- /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 e1a0e4844..bcf51398d 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 b5a1a98eb..1595d4443 100644 --- a/src/test/jtx/Account.h +++ b/src/test/jtx/Account.h @@ -73,6 +73,10 @@ public: /** @} */ + 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 @@ private: // 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 3a3e09597..a17186d4f 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 3df60d0e0..6e9a1b4f2 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 000000000..cfbcfe11c --- /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 b194f3011..35a46d19b 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 000000000..44f89087b --- /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 161404195..1f71abb3a 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 72ee6c893..bf49f2e31 100644 --- a/src/test/protocol/KnownFormatToGRPC_test.cpp +++ b/src/test/protocol/KnownFormatToGRPC_test.cpp @@ -235,72 +235,77 @@ private: // 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()) + 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]); + + // The ledger gives UNL all caps. Adapt to that. + if (size_t const i = name.find("Unl"); i != std::string::npos) { - // Repeated-type handling. - - // Munge the fieldDescriptor name so it looks like the - // name in sFields. - name = fieldDesc->camelcase_name(); - name[0] = toupper(name[0]); - - // 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'; - } - - if (!sFields.count(name)) - { - fail( - std::string("Repeated Protobuf Descriptor '") + - name + "' expected in KnownFormat '" + - knownFormatName + "' and not found", - __FILE__, - __LINE__); - return; - } - pass(); - - validateRepeatedField(fieldDesc, sFields.at(name)); + name[i + 1] = 'N'; + name[i + 2] = 'L'; } - else + + // 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) { - // Non-repeated handling. - pbuf::Descriptor const* const entryDesc = - fieldDesc->message_type(); - if (entryDesc == nullptr) - return; - - 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(); - - validateDescriptor( - entryDesc, sFields.at(entryDesc->name())); + name[i + 1] = 'F'; + name[i + 2] = 'T'; } - // 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); - }; + + if (!sFields.count(name)) + { + fail( + std::string("Repeated Protobuf Descriptor '") + 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; + + 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(); + + 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 @@ private: // 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 @@ private: {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 @@ private: 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 @@ private: // 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 @@ private: {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 c165aafd1..d89916edd 100644 --- a/src/test/protocol/STObject_test.cpp +++ b/src/test/protocol/STObject_test.cpp @@ -257,7 +257,7 @@ public: 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 a125f318c..8e1ec790b 100644 --- a/src/test/rpc/AccountSet_test.cpp +++ b/src/test/rpc/AccountSet_test.cpp @@ -75,6 +75,13 @@ public: // 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 @@ public: 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 fdcefbf66..1692b9806 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. From c66be3e6cfe97a154a2bcd549127443ccde52dcd Mon Sep 17 00:00:00 2001 From: manojsdoshi Date: Wed, 6 Apr 2022 13:30:48 -0700 Subject: [PATCH 6/8] Set version to 1.9.0-b3 --- src/ripple/protocol/impl/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ripple/protocol/impl/BuildInfo.cpp b/src/ripple/protocol/impl/BuildInfo.cpp index 9a261b81f..a52ea6020 100644 --- a/src/ripple/protocol/impl/BuildInfo.cpp +++ b/src/ripple/protocol/impl/BuildInfo.cpp @@ -33,7 +33,7 @@ namespace BuildInfo { // and follow the format described at http://semver.org/ //------------------------------------------------------------------------------ // clang-format off -char const* const versionString = "1.9.0-b2" +char const* const versionString = "1.9.0-b3" // clang-format on #if defined(DEBUG) || defined(SANITIZER) From cdd37a2a057bd380af35f55f9350e31940ef00da Mon Sep 17 00:00:00 2001 From: manojsdoshi Date: Wed, 6 Apr 2022 16:35:30 -0700 Subject: [PATCH 7/8] Set version to 1.9.0-rc1 --- src/ripple/protocol/impl/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ripple/protocol/impl/BuildInfo.cpp b/src/ripple/protocol/impl/BuildInfo.cpp index a52ea6020..f87a727b6 100644 --- a/src/ripple/protocol/impl/BuildInfo.cpp +++ b/src/ripple/protocol/impl/BuildInfo.cpp @@ -33,7 +33,7 @@ namespace BuildInfo { // and follow the format described at http://semver.org/ //------------------------------------------------------------------------------ // clang-format off -char const* const versionString = "1.9.0-b3" +char const* const versionString = "1.9.0-rc1" // clang-format on #if defined(DEBUG) || defined(SANITIZER) From 7c66747d27869f9f3c96617bd4227038f1fa92b8 Mon Sep 17 00:00:00 2001 From: manojsdoshi Date: Wed, 6 Apr 2022 20:28:37 -0700 Subject: [PATCH 8/8] Set version to 1.9.0 --- RELEASENOTES.md | 62 ++++++++++++++++++++++++++ src/ripple/protocol/impl/BuildInfo.cpp | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 99021ef10..d509f9d7e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -13,6 +13,68 @@ Have new ideas? Need help with setting up your node? Come visit us [here](https: # Releases +## Version 1.9.0 +This is the 1.9.0 release of `rippled`, the reference implementation of the XRP Ledger protocol. This release brings several features and improvements. + +### New and Improved Features +- **Introduce NFT support (XLS020):** This release introduces support for non-fungible tokens, currently available to the developer community for broader review and testing. Developers can create applications that allow users to mint, transfer, and ultimately burn (if desired) NFTs on the XRP Ledger. You can try out the new NFT transactions using the [nft-devnet](https://xrpl.org/xrp-testnet-faucet.html). Note that some fields and error codes from earlier releases of the supporting code have been refactored for this release, shown in the Code Refactoring section, below. [70779f](https://github.com/ripple/rippled/commit/70779f6850b5f33cdbb9cf4129bc1c259af0013e) + +- **Simplify the Job Queue:** This is a refactor aimed at cleaning up and simplifying the existing job queue. Currently, all jobs are canceled at the same time and in the same way, so this commit removes the unnecessary per-job cancellation token. [#3656](https://github.com/ripple/rippled/pull/3656) + +- **Optimize trust line caching:** The existing trust line caching code was suboptimal in that it stored redundant information, pinned SLEs into memory, and required multiple memory allocations per cached object. This commit eliminates redundant data, reduces the size of cached objects and unpinning SLEs from memory, and uses value types to avoid the need for `std::shared_ptr`. As a result of these changes, the effective size of a cached object includes the overhead of the memory allocator, and the `std::shared_ptr` should be reduced by at least 64 bytes. This is significant, as there can easily be tens of millions of these objects. [4d5459](https://github.com/ripple/rippled/commit/4d5459d041da8f5a349c5f458d664e5865e1f1b5) + +- **Incremental improvements to pathfinding memory usage:** This commit aborts background pathfinding when closed or disconnected, exits the pathfinding job thread if there are no requests left, does not create the path find a job if there are no requests, and refactors to remove the circular dependency between InfoSub and PathRequest. [#4111](https://github.com/ripple/rippled/pull/4111) + +- **Improve deterministic transaction sorting in TxQ:** This commit ensures that transactions with the same fee level are sorted by TxID XORed with the parent ledger hash, the TxQ is re-sorted after every ledger, and attempts to future-proof the TxQ tie-breaking test. [#4077](https://github.com/ripple/rippled/pull/4077) + +- **Improve stop signaling for Application:** [34ca45](https://github.com/ripple/rippled/commit/34ca45713244d0defc39549dd43821784b2a5c1d) + +- **Eliminate SHAMapInnerNode lock contention:** The `SHAMapInnerNode` class had a global mutex to protect the array of node children. Profiling suggested that around 4% of all attempts to lock the global would block. This commit removes that global mutex, and replaces it with a new per-node 16-way spinlock (implemented so as not to affect the size of an inner node object), effectively eliminating the lock contention. [1b9387](https://github.com/ripple/rippled/commit/1b9387eddc1f52165d3243d2ace9be0c62495eea) + +- **Improve ledger-fetching logic:** When fetching ledgers, the existing code would isolate the peer that sent the most useful responses, and issue follow-up queries only to that peer. This commit increases the query aggressiveness, and changes the mechanism used to select which peers to issue follow-up queries to so as to more evenly spread the load among those peers that provided useful responses. [48803a](https://github.com/ripple/rippled/commit/48803a48afc3bede55d71618c2ee38fd9dbfd3b0) + +- **Simplify and improve order book tracking:** The order book tracking code would use `std::shared_ptr` to track the lifetime of objects. This commit changes the logic to eliminate the overhead of `std::shared_ptr` by using value types, resulting in significant memory savings. [b9903b](https://github.com/ripple/rippled/commit/b9903bbcc483a384decf8d2665f559d123baaba2) + +- **Negative cache support for node store:** This commit allows the cache to service requests for nodes that were previously looked up but not found, reducing the need to perform I/O in several common scenarios. [3eb8aa](https://github.com/ripple/rippled/commit/3eb8aa8b80bd818f04c99cee2cfc243192709667) + +- **Improve asynchronous database handlers:** This commit optimizes the way asynchronous node store operations are processed, both by reducing the number of times locks are held and by minimizing the number of memory allocations and data copying. [6faaa9](https://github.com/ripple/rippled/commit/6faaa91850d6b2eb9fbf16c1256bf7ef11ac4646) + +- **Cleanup AcceptedLedger and AcceptedLedgerTx:** This commit modernizes the `AcceptedLedger` and `AcceptedLedgerTx` classes, reduces their memory footprint, and reduces unnecessary dynamic memory allocations. [8f5868](https://github.com/ripple/rippled/commit/8f586870917818133924bf2e11acab5321c2b588) + +### Code Refactoring + +This release includes name changes in the NFToken API for SFields, RPC return labels, and error codes for clarity and consistency. To refactor your code, migrate the names of these items to the new names as listed below. + +#### `SField` name changes: +* `TokenTaxon -> NFTokenTaxon` +* `MintedTokens -> MintedNFTokens` +* `BurnedTokens -> BurnedNFTokens` +* `TokenID -> NFTokenID` +* `TokenOffers -> NFTokenOffers` +* `BrokerFee -> NFTokenBrokerFee` +* `Minter -> NFTokenMinter` +* `NonFungibleToken -> NFToken` +* `NonFungibleTokens -> NFTokens` +* `BuyOffer -> NFTokenBuyOffer` +* `SellOffer -> NFTokenSellOffer` +* `OfferNode -> NFTokenOfferNode` + +#### RPC return labels +* `tokenid -> nft_id` +* `index -> nft_offer_index` + +#### Error codes +* `temBAD_TRANSFER_FEE -> temBAD_NFTOKEN_TRANSFER_FEE` +* `tefTOKEN_IS_NOT_TRANSFERABLE -> tefNFTOKEN_IS_NOT_TRANSFERABLE` +* `tecNO_SUITABLE_PAGE -> tecNO_SUITABLE_NFTOKEN_PAGE` +* `tecBUY_SELL_MISMATCH -> tecNFTOKEN_BUY_SELL_MISMATCH` +* `tecOFFER_TYPE_MISMATCH -> tecNFTOKEN_OFFER_TYPE_MISMATCH` +* `tecCANT_ACCEPT_OWN_OFFER -> tecCANT_ACCEPT_OWN_NFTOKEN_OFFER` + + +### Bug Fixes +- **Fix deletion of orphan node store directories:** Orphaned node store directories should only be deleted if the proper node store directories are confirmed to exist. [06e87e](https://github.com/ripple/rippled/commit/06e87e0f6add5b880d647e14ab3d950decfcf416) + ## Version 1.8.5 This is the 1.8.5 release of `rippled`, the reference implementation of the XRP Ledger protocol. This release includes fixes and updates for stability and security, and improvements to build scripts. There are no user-facing API or protocol changes in this release. diff --git a/src/ripple/protocol/impl/BuildInfo.cpp b/src/ripple/protocol/impl/BuildInfo.cpp index f87a727b6..6cd3668dd 100644 --- a/src/ripple/protocol/impl/BuildInfo.cpp +++ b/src/ripple/protocol/impl/BuildInfo.cpp @@ -33,7 +33,7 @@ namespace BuildInfo { // and follow the format described at http://semver.org/ //------------------------------------------------------------------------------ // clang-format off -char const* const versionString = "1.9.0-rc1" +char const* const versionString = "1.9.0" // clang-format on #if defined(DEBUG) || defined(SANITIZER)