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

@@ -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(