XLS-52d: NFTokenMintOffer (#4845)

This commit is contained in:
tequ
2024-06-15 01:32:25 +02:00
committed by GitHub
parent 3f5e3212fe
commit 9f7c619e4f
11 changed files with 927 additions and 241 deletions

View File

@@ -37,65 +37,24 @@ NFTokenCreateOffer::preflight(PreflightContext const& ctx)
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]);
{
STAmount const amount = ctx.tx[sfAmount];
if (amount.negative() && ctx.rules.enabled(fixNFTokenNegOffer))
// An offer for a negative amount makes no sense.
return temBAD_AMOUNT;
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])
{
// Some folks think it makes sense for a buy offer to specify a
// specific broker using the Destination field. This change doesn't
// deserve it's own amendment, so we're piggy-backing on
// fixNFTokenNegOffer.
//
// Prior to fixNFTokenNegOffer any use of the Destination field on
// a buy offer was malformed.
if (!isSellOffer && !ctx.rules.enabled(fixNFTokenNegOffer))
return temMALFORMED;
// The destination can't be the account executing the transaction.
if (dest == account)
return temMALFORMED;
}
// Use implementation shared with NFTokenMint
if (NotTEC notTec = nft::tokenOfferCreatePreflight(
ctx.tx[sfAccount],
ctx.tx[sfAmount],
ctx.tx[~sfDestination],
ctx.tx[~sfExpiration],
nftFlags,
ctx.rules,
ctx.tx[~sfOwner],
txFlags);
!isTesSuccess(notTec))
return notTec;
return preflight2(ctx);
}
@@ -106,182 +65,44 @@ 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);
uint256 const nftokenID = ctx.tx[sfNFTokenID];
std::uint32_t const txFlags = {ctx.tx.getFlags()};
if (!nft::findToken(
ctx.view, ctx.tx[isSellOffer ? sfAccount : sfOwner], nftokenID))
ctx.view,
ctx.tx[(txFlags & tfSellNFToken) ? 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)
{
// After this amendment, we allow an IOU issuer to make a buy offer
// using their own currency.
if (ctx.view.rules().enabled(fixNonFungibleTokensV1_2))
{
if (accountFunds(
ctx.view,
ctx.tx[sfAccount],
amount,
FreezeHandling::fhZERO_IF_FROZEN,
ctx.j)
.signum() <= 0)
return tecUNFUNDED_OFFER;
}
else if (
accountHolds(
ctx.view,
ctx.tx[sfAccount],
amount.getCurrency(),
amount.getIssuer(),
FreezeHandling::fhZERO_IF_FROZEN,
ctx.j)
.signum() <= 0)
return tecUNFUNDED_OFFER;
}
if (auto const destination = ctx.tx[~sfDestination])
{
// If a destination is specified, the destination must already be in
// the ledger.
auto const sleDst = ctx.view.read(keylet::account(*destination));
if (!sleDst)
return tecNO_DST;
// check if the destination has disallowed incoming offers
if (ctx.view.rules().enabled(featureDisallowIncoming))
{
// flag cannot be set unless amendment is enabled but
// out of an abundance of caution check anyway
if (sleDst->getFlags() & lsfDisallowIncomingNFTokenOffer)
return tecNO_PERMISSION;
}
}
if (auto const owner = ctx.tx[~sfOwner])
{
// Check if the owner (buy offer) has disallowed incoming offers
if (ctx.view.rules().enabled(featureDisallowIncoming))
{
auto const sleOwner = ctx.view.read(keylet::account(*owner));
// defensively check
// it should not be possible to specify owner that doesn't exist
if (!sleOwner)
return tecNO_TARGET;
if (sleOwner->getFlags() & lsfDisallowIncomingNFTokenOffer)
return tecNO_PERMISSION;
}
}
return tesSUCCESS;
// Use implementation shared with NFTokenMint
return nft::tokenOfferCreatePreclaim(
ctx.view,
ctx.tx[sfAccount],
nft::getIssuer(nftokenID),
ctx.tx[sfAmount],
ctx.tx[~sfDestination],
nft::getFlags(nftokenID),
nft::getTransferFee(nftokenID),
ctx.j,
ctx.tx[~sfOwner],
txFlags);
}
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<SLE> 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<SLE>(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;
// Use implementation shared with NFTokenMint
return nft::tokenOfferCreateApply(
view(),
ctx_.tx[sfAccount],
ctx_.tx[sfAmount],
ctx_.tx[~sfDestination],
ctx_.tx[~sfExpiration],
ctx_.tx.getSeqProxy(),
ctx_.tx[sfNFTokenID],
mPriorBalance,
j_,
ctx_.tx.getFlags());
}
} // namespace ripple

View File

@@ -31,12 +31,25 @@
namespace ripple {
static std::uint16_t
extractNFTokenFlagsFromTxFlags(std::uint32_t txFlags)
{
return static_cast<std::uint16_t>(txFlags & 0x0000FFFF);
}
NotTEC
NFTokenMint::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featureNonFungibleTokensV1))
return temDISABLED;
bool const hasOfferFields = ctx.tx.isFieldPresent(sfAmount) ||
ctx.tx.isFieldPresent(sfDestination) ||
ctx.tx.isFieldPresent(sfExpiration);
if (!ctx.rules.enabled(featureNFTokenMintOffer) && hasOfferFields)
return temDISABLED;
if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;
@@ -80,6 +93,29 @@ NFTokenMint::preflight(PreflightContext const& ctx)
return temMALFORMED;
}
if (hasOfferFields)
{
// The Amount field must be present if either the Destination or
// Expiration fields are present.
if (!ctx.tx.isFieldPresent(sfAmount))
return temMALFORMED;
// Rely on the common code shared with NFTokenCreateOffer to
// do the validation. We pass tfSellNFToken as the transaction flags
// because a Mint is only allowed to create a sell offer.
if (NotTEC notTec = nft::tokenOfferCreatePreflight(
ctx.tx[sfAccount],
ctx.tx[sfAmount],
ctx.tx[~sfDestination],
ctx.tx[~sfExpiration],
extractNFTokenFlagsFromTxFlags(ctx.tx.getFlags()),
ctx.rules);
!isTesSuccess(notTec))
{
return notTec;
}
}
return preflight2(ctx);
}
@@ -146,6 +182,27 @@ NFTokenMint::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION;
}
if (ctx.tx.isFieldPresent(sfAmount))
{
// The Amount field says create an offer for the minted token.
if (hasExpired(ctx.view, ctx.tx[~sfExpiration]))
return tecEXPIRED;
// Rely on the common code shared with NFTokenCreateOffer to
// do the validation. We pass tfSellNFToken as the transaction flags
// because a Mint is only allowed to create a sell offer.
if (TER const ter = nft::tokenOfferCreatePreclaim(
ctx.view,
ctx.tx[sfAccount],
ctx.tx[~sfIssuer].value_or(ctx.tx[sfAccount]),
ctx.tx[sfAmount],
ctx.tx[~sfDestination],
extractNFTokenFlagsFromTxFlags(ctx.tx.getFlags()),
ctx.tx[~sfTransferFee].value_or(0),
ctx.j);
!isTesSuccess(ter))
return ter;
}
return tesSUCCESS;
}
@@ -238,18 +295,16 @@ NFTokenMint::doApply()
// Should never happen.
return tecINTERNAL;
auto const nftokenID = createNFTokenID(
extractNFTokenFlagsFromTxFlags(ctx_.tx.getFlags()),
ctx_.tx[~sfTransferFee].value_or(0),
issuer,
nft::toTaxon(ctx_.tx[sfNFTokenTaxon]),
tokenSeq.value());
STObject newToken(
*nfTokenTemplate,
sfNFToken,
[this, &issuer, &tokenSeq](STObject& object) {
object.setFieldH256(
sfNFTokenID,
createNFTokenID(
static_cast<std::uint16_t>(ctx_.tx.getFlags() & 0x0000FFFF),
ctx_.tx[~sfTransferFee].value_or(0),
issuer,
nft::toTaxon(ctx_.tx[sfNFTokenTaxon]),
tokenSeq.value()));
*nfTokenTemplate, sfNFToken, [this, &nftokenID](STObject& object) {
object.setFieldH256(sfNFTokenID, nftokenID);
if (auto const uri = ctx_.tx[~sfURI])
object.setFieldVL(sfURI, *uri);
@@ -260,10 +315,29 @@ NFTokenMint::doApply()
ret != tesSUCCESS)
return ret;
if (ctx_.tx.isFieldPresent(sfAmount))
{
// Rely on the common code shared with NFTokenCreateOffer to create
// the offer. We pass tfSellNFToken as the transaction flags
// because a Mint is only allowed to create a sell offer.
if (TER const ter = nft::tokenOfferCreateApply(
view(),
ctx_.tx[sfAccount],
ctx_.tx[sfAmount],
ctx_.tx[~sfDestination],
ctx_.tx[~sfExpiration],
ctx_.tx.getSeqProxy(),
nftokenID,
mPriorBalance,
j_);
!isTesSuccess(ter))
return ter;
}
// 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.
// only managed when a new NFT page or sell offer is added.
if (auto const ownerCountAfter =
view().read(keylet::account(account_))->getFieldU32(sfOwnerCount);
ownerCountAfter > ownerCountBefore)

View File

@@ -636,5 +636,252 @@ deleteTokenOffer(ApplyView& view, std::shared_ptr<SLE> const& offer)
return true;
}
NotTEC
tokenOfferCreatePreflight(
AccountID const& acctID,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::optional<std::uint32_t> const& expiration,
std::uint16_t nftFlags,
Rules const& rules,
std::optional<AccountID> const& owner,
std::uint32_t txFlags)
{
if (amount.negative() && rules.enabled(fixNFTokenNegOffer))
// An offer for a negative amount makes no sense.
return temBAD_AMOUNT;
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.
bool const isSellOffer = txFlags & tfSellNFToken;
if (!isSellOffer && !amount)
return temBAD_AMOUNT;
if (expiration.has_value() && expiration.value() == 0)
return temBAD_EXPIRATION;
// 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 == acctID)
return temMALFORMED;
if (dest)
{
// Some folks think it makes sense for a buy offer to specify a
// specific broker using the Destination field. This change doesn't
// deserve it's own amendment, so we're piggy-backing on
// fixNFTokenNegOffer.
//
// Prior to fixNFTokenNegOffer any use of the Destination field on
// a buy offer was malformed.
if (!isSellOffer && !rules.enabled(fixNFTokenNegOffer))
return temMALFORMED;
// The destination can't be the account executing the transaction.
if (dest == acctID)
return temMALFORMED;
}
return tesSUCCESS;
}
TER
tokenOfferCreatePreclaim(
ReadView const& view,
AccountID const& acctID,
AccountID const& nftIssuer,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::uint16_t nftFlags,
std::uint16_t xferFee,
beast::Journal j,
std::optional<AccountID> const& owner,
std::uint32_t txFlags)
{
if (!(nftFlags & nft::flagCreateTrustLines) && !amount.native() && xferFee)
{
if (!view.exists(keylet::account(nftIssuer)))
return tecNO_ISSUER;
// If the IOU issuer and the NFToken issuer are the same, then that
// issuer does not need a trust line to accept their fee.
if (view.rules().enabled(featureNFTokenMintOffer))
{
if (nftIssuer != amount.getIssuer() &&
!view.read(keylet::line(nftIssuer, amount.issue())))
return tecNO_LINE;
}
else if (!view.exists(keylet::line(nftIssuer, amount.issue())))
{
return tecNO_LINE;
}
if (isFrozen(view, nftIssuer, amount.getCurrency(), amount.getIssuer()))
return tecFROZEN;
}
if (nftIssuer != acctID && !(nftFlags & nft::flagTransferable))
{
auto const root = view.read(keylet::account(nftIssuer));
assert(root);
if (auto minter = (*root)[~sfNFTokenMinter]; minter != acctID)
return tefNFTOKEN_IS_NOT_TRANSFERABLE;
}
if (isFrozen(view, acctID, 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 ((txFlags & tfSellNFToken) == 0)
{
// After this amendment, we allow an IOU issuer to make a buy offer
// using their own currency.
if (view.rules().enabled(fixNonFungibleTokensV1_2))
{
if (accountFunds(
view, acctID, amount, FreezeHandling::fhZERO_IF_FROZEN, j)
.signum() <= 0)
return tecUNFUNDED_OFFER;
}
else if (
accountHolds(
view,
acctID,
amount.getCurrency(),
amount.getIssuer(),
FreezeHandling::fhZERO_IF_FROZEN,
j)
.signum() <= 0)
return tecUNFUNDED_OFFER;
}
if (dest)
{
// If a destination is specified, the destination must already be in
// the ledger.
auto const sleDst = view.read(keylet::account(*dest));
if (!sleDst)
return tecNO_DST;
// check if the destination has disallowed incoming offers
if (view.rules().enabled(featureDisallowIncoming))
{
// flag cannot be set unless amendment is enabled but
// out of an abundance of caution check anyway
if (sleDst->getFlags() & lsfDisallowIncomingNFTokenOffer)
return tecNO_PERMISSION;
}
}
if (owner)
{
// Check if the owner (buy offer) has disallowed incoming offers
if (view.rules().enabled(featureDisallowIncoming))
{
auto const sleOwner = view.read(keylet::account(*owner));
// defensively check
// it should not be possible to specify owner that doesn't exist
if (!sleOwner)
return tecNO_TARGET;
if (sleOwner->getFlags() & lsfDisallowIncomingNFTokenOffer)
return tecNO_PERMISSION;
}
}
return tesSUCCESS;
}
TER
tokenOfferCreateApply(
ApplyView& view,
AccountID const& acctID,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::optional<std::uint32_t> const& expiration,
SeqProxy seqProxy,
uint256 const& nftokenID,
XRPAmount const& priorBalance,
beast::Journal j,
std::uint32_t txFlags)
{
Keylet const acctKeylet = keylet::account(acctID);
if (auto const acct = view.read(acctKeylet);
priorBalance < view.fees().accountReserve((*acct)[sfOwnerCount] + 1))
return tecINSUFFICIENT_RESERVE;
auto const offerID = keylet::nftoffer(acctID, seqProxy.value());
// Create the offer:
{
// Token offers are always added to the owner's owner directory:
auto const ownerNode = view.dirInsert(
keylet::ownerDir(acctID), offerID, describeOwnerDir(acctID));
if (!ownerNode)
return tecDIR_FULL;
bool const isSellOffer = txFlags & 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<SLE> 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<SLE>(offerID);
(*offer)[sfOwner] = acctID;
(*offer)[sfNFTokenID] = nftokenID;
(*offer)[sfAmount] = amount;
(*offer)[sfFlags] = sleFlags;
(*offer)[sfOwnerNode] = *ownerNode;
(*offer)[sfNFTokenOfferNode] = *offerNode;
if (expiration)
(*offer)[sfExpiration] = *expiration;
if (dest)
(*offer)[sfDestination] = *dest;
view.insert(offer);
}
// Update owner count.
adjustOwnerCount(view, view.peek(acctKeylet), 1, j);
return tesSUCCESS;
}
} // namespace nft
} // namespace ripple

View File

@@ -20,6 +20,7 @@
#ifndef RIPPLE_TX_IMPL_DETAILS_NFTOKENUTILS_H_INCLUDED
#define RIPPLE_TX_IMPL_DETAILS_NFTOKENUTILS_H_INCLUDED
#include <ripple/app/tx/impl/Transactor.h>
#include <ripple/basics/base_uint.h>
#include <ripple/basics/tagged_integer.h>
#include <ripple/ledger/ApplyView.h>
@@ -97,6 +98,46 @@ deleteTokenOffer(ApplyView& view, std::shared_ptr<SLE> const& offer);
bool
compareTokens(uint256 const& a, uint256 const& b);
/** Preflight checks shared by NFTokenCreateOffer and NFTokenMint */
NotTEC
tokenOfferCreatePreflight(
AccountID const& acctID,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::optional<std::uint32_t> const& expiration,
std::uint16_t nftFlags,
Rules const& rules,
std::optional<AccountID> const& owner = std::nullopt,
std::uint32_t txFlags = lsfSellNFToken);
/** Preclaim checks shared by NFTokenCreateOffer and NFTokenMint */
TER
tokenOfferCreatePreclaim(
ReadView const& view,
AccountID const& acctID,
AccountID const& nftIssuer,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::uint16_t nftFlags,
std::uint16_t xferFee,
beast::Journal j,
std::optional<AccountID> const& owner = std::nullopt,
std::uint32_t txFlags = lsfSellNFToken);
/** doApply implementation shared by NFTokenCreateOffer and NFTokenMint */
TER
tokenOfferCreateApply(
ApplyView& view,
AccountID const& acctID,
STAmount const& amount,
std::optional<AccountID> const& dest,
std::optional<std::uint32_t> const& expiration,
SeqProxy seqProxy,
uint256 const& nftokenID,
XRPAmount const& priorBalance,
beast::Journal j,
std::uint32_t txFlags = lsfSellNFToken);
} // namespace nft
} // namespace ripple

View File

@@ -80,7 +80,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 = 74;
static constexpr std::size_t numFeatures = 75;
/** Amendments that this server supports and the default voting behavior.
Whether they are enabled depends on the Rules defined in the validated
@@ -366,6 +366,7 @@ extern uint256 const fixEmptyDID;
extern uint256 const fixXChainRewardRounding;
extern uint256 const fixPreviousTxnID;
extern uint256 const fixAMMv1_1;
extern uint256 const featureNFTokenMintOffer;
extern uint256 const fixReducedOffersV2;
} // namespace ripple

View File

@@ -493,6 +493,7 @@ REGISTER_FIX (fixEmptyDID, Supported::yes, VoteBehavior::De
REGISTER_FIX (fixXChainRewardRounding, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FIX (fixPreviousTxnID, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FIX (fixAMMv1_1, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FEATURE(NFTokenMintOffer, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FIX (fixReducedOffersV2, Supported::yes, VoteBehavior::DefaultNo);
// The following amendments are obsolete, but must remain supported

View File

@@ -31,7 +31,8 @@ canHaveNFTokenOfferID(
return false;
TxType const tt = serializedTx->getTxnType();
if (tt != ttNFTOKEN_CREATE_OFFER)
if (!(tt == ttNFTOKEN_MINT && serializedTx->isFieldPresent(sfAmount)) &&
tt != ttNFTOKEN_CREATE_OFFER)
return false;
// if the transaction failed nothing could have been delivered.

View File

@@ -333,6 +333,9 @@ TxFormats::TxFormats()
{sfTransferFee, soeOPTIONAL},
{sfIssuer, soeOPTIONAL},
{sfURI, soeOPTIONAL},
{sfAmount, soeOPTIONAL},
{sfDestination, soeOPTIONAL},
{sfExpiration, soeOPTIONAL},
},
commonFields);

View File

@@ -3171,6 +3171,26 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite
ter(tecNO_PERMISSION));
env.close();
}
// minter mint and offer to buyer
if (features[featureNFTokenMintOffer])
{
// enable flag
env(fset(buyer, asfDisallowIncomingNFTokenOffer));
// a sell offer from the minter to the buyer should be rejected
env(token::mint(minter),
token::amount(drops(1)),
token::destination(buyer),
ter(tecNO_PERMISSION));
env.close();
// disable flag
env(fclear(buyer, asfDisallowIncomingNFTokenOffer));
env(token::mint(minter),
token::amount(drops(1)),
token::destination(buyer));
env.close();
}
}
void
@@ -6566,6 +6586,281 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite
}
}
void
testFeatMintWithOffer(FeatureBitset features)
{
testcase("NFTokenMint with Create NFTokenOffer");
using namespace test::jtx;
if (!features[featureNFTokenMintOffer])
{
Env env{*this, features};
Account const alice("alice");
Account const buyer("buyer");
env.fund(XRP(10000), alice, buyer);
env.close();
env(token::mint(alice),
token::amount(XRP(10000)),
ter(temDISABLED));
env.close();
env(token::mint(alice),
token::destination("buyer"),
ter(temDISABLED));
env.close();
env(token::mint(alice),
token::expiration(lastClose(env) + 25),
ter(temDISABLED));
env.close();
return;
}
// The remaining tests assume featureNFTokenMintOffer is enabled.
{
Env env{*this, features};
Account const alice("alice");
Account const buyer{"buyer"};
Account const gw("gw");
Account const issuer("issuer");
Account const minter("minter");
Account const bob("bob");
IOU const gwAUD(gw["AUD"]);
env.fund(XRP(10000), alice, buyer, gw, issuer, minter);
env.close();
{
// Destination field specified but Amount field not specified
env(token::mint(alice),
token::destination(buyer),
ter(temMALFORMED));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// Expiration field specified but Amount field not specified
env(token::mint(alice),
token::expiration(lastClose(env) + 25),
ter(temMALFORMED));
env.close();
BEAST_EXPECT(ownerCount(env, buyer) == 0);
}
{
// The destination may not be the account submitting the
// transaction.
env(token::mint(alice),
token::amount(XRP(1000)),
token::destination(alice),
ter(temMALFORMED));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// The destination must be an account already established in the
// ledger.
env(token::mint(alice),
token::amount(XRP(1000)),
token::destination(Account("demon")),
ter(tecNO_DST));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
}
{
// Set a bad expiration.
env(token::mint(alice),
token::amount(XRP(1000)),
token::expiration(0),
ter(temBAD_EXPIRATION));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// The new NFTokenOffer may not have passed its expiration time.
env(token::mint(alice),
token::amount(XRP(1000)),
token::expiration(lastClose(env)),
ter(tecEXPIRED));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
}
{
// Set an invalid amount.
env(token::mint(alice),
token::amount(buyer["USD"](1)),
txflags(tfOnlyXRP),
ter(temBAD_AMOUNT));
env(token::mint(alice),
token::amount(buyer["USD"](0)),
ter(temBAD_AMOUNT));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// Issuer (alice) must have a trust line for the offered funds.
env(token::mint(alice),
token::amount(gwAUD(1000)),
txflags(tfTransferable),
token::xferFee(10),
ter(tecNO_LINE));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// If the IOU issuer and the NFToken issuer are the same,
// then that issuer does not need a trust line to accept their
// fee.
env(token::mint(gw),
token::amount(gwAUD(1000)),
txflags(tfTransferable),
token::xferFee(10));
env.close();
// 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::mint(alice),
token::amount(gwAUD(1000)),
txflags(tfTransferable),
token::xferFee(10),
ter(tecFROZEN));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// Seller (alice) must have a trust line may not be frozen.
env(token::mint(alice),
token::amount(gwAUD(1000)),
ter(tecFROZEN));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
// Unfreeze alice's trustline.
env(trust(gw, alice["AUD"](999), tfClearFreeze));
env.close();
}
{
// check reserve
auto const acctReserve =
env.current()->fees().accountReserve(0);
auto const incReserve = env.current()->fees().increment;
env.fund(acctReserve + incReserve, bob);
env.close();
// doesn't have reserve for 2 objects (NFTokenPage, Offer)
env(token::mint(bob),
token::amount(XRP(0)),
ter(tecINSUFFICIENT_RESERVE));
env.close();
// have reserve for NFTokenPage, Offer
env(pay(env.master, bob, incReserve + drops(10)));
env.close();
env(token::mint(bob), token::amount(XRP(0)));
env.close();
// doesn't have reserve for Offer
env(pay(env.master, bob, drops(10)));
env.close();
env(token::mint(bob),
token::amount(XRP(0)),
ter(tecINSUFFICIENT_RESERVE));
env.close();
// have reserve for Offer
env(pay(env.master, bob, incReserve + drops(10)));
env.close();
env(token::mint(bob), token::amount(XRP(0)));
env.close();
}
// Amount field specified
BEAST_EXPECT(ownerCount(env, alice) == 0);
env(token::mint(alice), token::amount(XRP(10)));
BEAST_EXPECT(ownerCount(env, alice) == 2);
env.close();
// Amount field and Destination field, Expiration field specified
env(token::mint(alice),
token::amount(XRP(10)),
token::destination(buyer),
token::expiration(lastClose(env) + 25));
env.close();
// With TransferFee field
env(trust(alice, gwAUD(1000)));
env.close();
env(token::mint(alice),
token::amount(gwAUD(1)),
token::destination(buyer),
token::expiration(lastClose(env) + 25),
txflags(tfTransferable),
token::xferFee(10));
env.close();
// Can be canceled by the issuer.
env(token::mint(alice),
token::amount(XRP(10)),
token::destination(buyer),
token::expiration(lastClose(env) + 25));
uint256 const offerAliceSellsToBuyer =
keylet::nftoffer(alice, env.seq(alice)).key;
env(token::cancelOffer(alice, {offerAliceSellsToBuyer}));
env.close();
// Can be canceled by the buyer.
env(token::mint(buyer),
token::amount(XRP(10)),
token::destination(alice),
token::expiration(lastClose(env) + 25));
uint256 const offerBuyerSellsToAlice =
keylet::nftoffer(buyer, env.seq(buyer)).key;
env(token::cancelOffer(alice, {offerBuyerSellsToAlice}));
env.close();
env(token::setMinter(issuer, minter));
env.close();
// Minter will have offer not issuer
BEAST_EXPECT(ownerCount(env, minter) == 0);
BEAST_EXPECT(ownerCount(env, issuer) == 0);
env(token::mint(minter),
token::issuer(issuer),
token::amount(drops(1)));
env.close();
BEAST_EXPECT(ownerCount(env, minter) == 2);
BEAST_EXPECT(ownerCount(env, issuer) == 0);
}
// Test sell offers with a destination with and without
// fixNFTokenNegOffer.
for (auto const& tweakedFeatures :
{features - fixNFTokenNegOffer - featureNonFungibleTokensV1_1,
features | fixNFTokenNegOffer})
{
Env env{*this, tweakedFeatures};
Account const alice("alice");
env.fund(XRP(1000000), alice);
TER const offerCreateTER = tweakedFeatures[fixNFTokenNegOffer]
? static_cast<TER>(temBAD_AMOUNT)
: static_cast<TER>(tesSUCCESS);
// Make offers with negative amounts for the NFTs
env(token::mint(alice),
token::amount(XRP(-2)),
ter(offerCreateTER));
env.close();
}
}
void
testTxJsonMetaFields(FeatureBitset features)
{
@@ -6796,6 +7091,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite
env.close();
verifyNFTokenIDsInCancelOffer({nftId});
}
if (features[featureNFTokenMintOffer])
{
uint256 const aliceMintWithOfferIndex1 =
keylet::nftoffer(alice, env.seq(alice)).key;
env(token::mint(alice), token::amount(XRP(0)));
env.close();
verifyNFTokenOfferID(aliceMintWithOfferIndex1);
}
}
void
@@ -7112,6 +7416,164 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite
}
}
void
testNFTIssuerIsIOUIssuer(FeatureBitset features)
{
testcase("Test fix NFT issuer is IOU issuer");
using namespace test::jtx;
Account const issuer{"issuer"};
Account const becky{"becky"};
Account const cheri{"cheri"};
IOU const isISU(issuer["ISU"]);
// This test case covers issue...
// https://github.com/XRPLF/rippled/issues/4941
//
// If an NFToken has a transfer fee then, when an offer is accepted,
// a portion of the sale price goes to the issuer.
//
// It is possible for an issuer to issue both an IOU (for remittances)
// and NFTokens. If the issuer's IOU is used to pay for the transfer
// of one of the issuer's NFTokens, then paying the fee for that
// transfer will fail with a tecNO_LINE.
//
// The problem occurs because the NFT code looks for a trust line to
// pay the transfer fee. However the issuer of an IOU does not need
// a trust line to accept their own issuance and, in fact, is not
// allowed to have a trust line to themselves.
//
// This test looks at a situation where transfer of an NFToken is
// prevented by this bug:
// 1. Issuer issues an IOU (e.g, isISU).
// 2. Becky and Cheri get trust lines for, and acquire, some isISU.
// 3. Issuer mints NFToken with transfer fee.
// 4. Becky acquires the NFToken, paying with XRP.
// 5. Becky attempts to create an offer to sell the NFToken for
// isISU(100). The attempt fails with `tecNO_LINE`.
//
// The featureNFTokenMintOffer amendment addresses this oversight.
//
// We remove the fixRemoveNFTokenAutoTrustLine amendment. Otherwise
// we can't create NFTokens with tfTrustLine enabled.
FeatureBitset const localFeatures =
features - fixRemoveNFTokenAutoTrustLine;
Env env{*this, localFeatures};
env.fund(XRP(1000), issuer, becky, cheri);
env.close();
// Set trust lines so becky and cheri can use isISU.
env(trust(becky, isISU(1000)));
env(trust(cheri, isISU(1000)));
env.close();
env(pay(issuer, cheri, isISU(500)));
env.close();
// issuer creates two NFTs: one with and one without AutoTrustLine.
std::uint16_t xferFee = 5000; // 5%
uint256 const nftAutoTrustID{token::getNextID(
env, issuer, 0u, tfTransferable | tfTrustLine, xferFee)};
env(token::mint(issuer, 0u),
token::xferFee(xferFee),
txflags(tfTransferable | tfTrustLine));
env.close();
uint256 const nftNoAutoTrustID{
token::getNextID(env, issuer, 0u, tfTransferable, xferFee)};
env(token::mint(issuer, 0u),
token::xferFee(xferFee),
txflags(tfTransferable));
env.close();
// becky buys the nfts for 1 drop each.
{
uint256 const beckyBuyOfferIndex1 =
keylet::nftoffer(becky, env.seq(becky)).key;
env(token::createOffer(becky, nftAutoTrustID, drops(1)),
token::owner(issuer));
uint256 const beckyBuyOfferIndex2 =
keylet::nftoffer(becky, env.seq(becky)).key;
env(token::createOffer(becky, nftNoAutoTrustID, drops(1)),
token::owner(issuer));
env.close();
env(token::acceptBuyOffer(issuer, beckyBuyOfferIndex1));
env(token::acceptBuyOffer(issuer, beckyBuyOfferIndex2));
env.close();
}
// Behavior from here down diverges significantly based on
// featureNFTokenMintOffer.
if (!localFeatures[featureNFTokenMintOffer])
{
// Without featureNFTokenMintOffer becky simply can't
// create an offer for a non-tfTrustLine NFToken that would
// pay the transfer fee in issuer's own IOU.
env(token::createOffer(becky, nftNoAutoTrustID, isISU(100)),
txflags(tfSellNFToken),
ter(tecNO_LINE));
env.close();
// And issuer can't create a trust line to themselves.
env(trust(issuer, isISU(1000)), ter(temDST_IS_SRC));
env.close();
// However if the NFToken has the tfTrustLine flag set,
// then becky can create the offer.
uint256 const beckyAutoTrustOfferIndex =
keylet::nftoffer(becky, env.seq(becky)).key;
env(token::createOffer(becky, nftAutoTrustID, isISU(100)),
txflags(tfSellNFToken));
env.close();
// And cheri can accept the offer.
env(token::acceptSellOffer(cheri, beckyAutoTrustOfferIndex));
env.close();
// We verify that issuer got their transfer fee by seeing that
// ISU(5) has disappeared out of cheri's and becky's balances.
BEAST_EXPECT(env.balance(becky, isISU) == isISU(95));
BEAST_EXPECT(env.balance(cheri, isISU) == isISU(400));
}
else
{
// With featureNFTokenMintOffer things go better.
// becky creates offers to sell the nfts for ISU.
uint256 const beckyNoAutoTrustOfferIndex =
keylet::nftoffer(becky, env.seq(becky)).key;
env(token::createOffer(becky, nftNoAutoTrustID, isISU(100)),
txflags(tfSellNFToken));
env.close();
uint256 const beckyAutoTrustOfferIndex =
keylet::nftoffer(becky, env.seq(becky)).key;
env(token::createOffer(becky, nftAutoTrustID, isISU(100)),
txflags(tfSellNFToken));
env.close();
// cheri accepts becky's offers. Behavior is uniform:
// issuer gets paid.
env(token::acceptSellOffer(cheri, beckyAutoTrustOfferIndex));
env.close();
// We verify that issuer got their transfer fee by seeing that
// ISU(5) has disappeared out of cheri's and becky's balances.
BEAST_EXPECT(env.balance(becky, isISU) == isISU(95));
BEAST_EXPECT(env.balance(cheri, isISU) == isISU(400));
env(token::acceptSellOffer(cheri, beckyNoAutoTrustOfferIndex));
env.close();
// We verify that issuer got their transfer fee by seeing that
// an additional ISU(5) has disappeared out of cheri's and
// becky's balances.
BEAST_EXPECT(env.balance(becky, isISU) == isISU(190));
BEAST_EXPECT(env.balance(cheri, isISU) == isISU(300));
}
}
void
testWithFeats(FeatureBitset features)
{
@@ -7144,8 +7606,10 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite
testIOUWithTransferFee(features);
testBrokeredSaleToSelf(features);
testFixNFTokenRemint(features);
testFeatMintWithOffer(features);
testTxJsonMetaFields(features);
testFixNFTokenBuyerReserve(features);
testNFTIssuerIsIOUIssuer(features);
}
public:
@@ -7156,15 +7620,17 @@ public:
static FeatureBitset const all{supported_amendments()};
static FeatureBitset const fixNFTDir{fixNFTokenDirV1};
static std::array<FeatureBitset, 6> const feats{
static std::array<FeatureBitset, 7> const feats{
all - fixNFTDir - fixNonFungibleTokensV1_2 - fixNFTokenRemint -
fixNFTokenReserve,
fixNFTokenReserve - featureNFTokenMintOffer,
all - disallowIncoming - fixNonFungibleTokensV1_2 -
fixNFTokenRemint - fixNFTokenReserve,
fixNFTokenRemint - fixNFTokenReserve - featureNFTokenMintOffer,
all - fixNonFungibleTokensV1_2 - fixNFTokenRemint -
fixNFTokenReserve,
all - fixNFTokenRemint - fixNFTokenReserve,
all - fixNFTokenReserve,
fixNFTokenReserve - featureNFTokenMintOffer,
all - fixNFTokenRemint - fixNFTokenReserve -
featureNFTokenMintOffer,
all - fixNFTokenReserve - featureNFTokenMintOffer,
all - featureNFTokenMintOffer,
all};
if (BEAST_EXPECT(instance < feats.size()))
@@ -7217,12 +7683,21 @@ class NFTokenWOTokenReserve_test : public NFTokenBaseUtil_test
}
};
class NFTokenWOMintOffer_test : public NFTokenBaseUtil_test
{
void
run() override
{
NFTokenBaseUtil_test::run(5);
}
};
class NFTokenAllFeatures_test : public NFTokenBaseUtil_test
{
void
run() override
{
NFTokenBaseUtil_test::run(5, true);
NFTokenBaseUtil_test::run(6, true);
}
};
@@ -7231,6 +7706,7 @@ BEAST_DEFINE_TESTSUITE_PRIO(NFTokenDisallowIncoming, tx, ripple, 2);
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOfixV1, tx, ripple, 2);
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOTokenRemint, tx, ripple, 2);
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOTokenReserve, tx, ripple, 2);
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOMintOffer, tx, ripple, 2);
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenAllFeatures, tx, ripple, 2);
} // namespace ripple

View File

@@ -57,6 +57,12 @@ uri::operator()(Env& env, JTx& jt) const
jt.jv[sfURI.jsonName] = uri_;
}
void
amount::operator()(Env& env, JTx& jt) const
{
jt.jv[sfAmount.jsonName] = amount_.getJson(JsonOptions::none);
}
uint256
getNextID(
jtx::Env const& env,

View File

@@ -83,6 +83,21 @@ public:
operator()(Env&, JTx& jtx) const;
};
/** Sets the optional amount field on an NFTokenMint. */
class amount
{
private:
STAmount const amount_;
public:
explicit amount(STAmount const amount) : amount_(amount)
{
}
void
operator()(Env&, JTx& jtx) const;
};
/** Get the next NFTokenID that will be issued. */
uint256
getNextID(