mirror of
https://github.com/XRPLF/rippled.git
synced 2025-11-20 11:05:54 +00:00
7667 lines
302 KiB
C++
7667 lines
302 KiB
C++
#include <test/jtx.h>
|
|
|
|
#include <xrpld/app/tx/detail/NFTokenUtils.h>
|
|
|
|
#include <xrpl/basics/random.h>
|
|
#include <xrpl/protocol/Feature.h>
|
|
#include <xrpl/protocol/jss.h>
|
|
|
|
#include <initializer_list>
|
|
|
|
namespace ripple {
|
|
|
|
class NFTokenBaseUtil_test : public beast::unit_test::suite
|
|
{
|
|
FeatureBitset const disallowIncoming{featureDisallowIncoming};
|
|
|
|
// 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 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.
|
|
auto const acctReserve = env.current()->fees().reserve;
|
|
auto const incReserve = env.current()->fees().increment;
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
env.fund(acctReserve, alice, minter);
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(alice) == acctReserve);
|
|
BEAST_EXPECT(env.balance(minter) == acctReserve);
|
|
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, incReserve + drops(baseFee - 1)));
|
|
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(baseFee + 1)));
|
|
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, incReserve + drops(baseFee * 33 - 1)));
|
|
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(baseFee + 1)));
|
|
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(env, 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(env, 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, incReserve - 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(baseFee + 1)));
|
|
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, incReserve + drops(baseFee * 32 - 1)));
|
|
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(baseFee + 1)));
|
|
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(env, 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(env, 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(env, 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, sle->key());
|
|
if (replacement->getFieldU32(sfMintedNFTokens) != 1)
|
|
return false; // Unexpected test conditions.
|
|
|
|
// Wequence number is generated by sfFirstNFTokenSequence +
|
|
// sfMintedNFTokens. We can replace the two fields with any
|
|
// numbers as long as they add up to the largest valid number.
|
|
// In our case, sfFirstNFTokenSequence is set to the largest
|
|
// valid number, and sfMintedNFTokens is set to zero.
|
|
(*replacement)[sfFirstNFTokenSequence] = 0xFFFF'FFFE;
|
|
(*replacement)[sfMintedNFTokens] = 0x0000'0000;
|
|
view.rawReplace(replacement);
|
|
return true;
|
|
});
|
|
|
|
// See whether alice is at the boundary that causes an error.
|
|
env(token::mint(alice, 0u), ter(tesSUCCESS));
|
|
env(token::mint(alice, 0u), ter(tecMAX_SEQUENCE_REACHED));
|
|
}
|
|
|
|
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));
|
|
|
|
//----------------------------------------------------------------------
|
|
// preclaim
|
|
|
|
// 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(env, 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(env, 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(env, 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.
|
|
auto const baseFee = env.current()->fees().base;
|
|
env(pay(env.master, buyer, XRP(50) + drops(baseFee * 12 - 1)));
|
|
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(baseFee + 1)));
|
|
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<uint256> 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 non-zero.
|
|
env(token::acceptBuyOffer(buyer, beast::zero),
|
|
ter(tecOBJECT_NOT_FOUND));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 0);
|
|
|
|
// 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 non-zero.
|
|
env(token::acceptSellOffer(buyer, beast::zero),
|
|
ter(tecOBJECT_NOT_FOUND));
|
|
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;
|
|
|
|
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"]);
|
|
|
|
// The behavior of this test changes dramatically based on the
|
|
// presence (or absence) of the fixRemoveNFTokenAutoTrustLine
|
|
// amendment. So we test both cases here.
|
|
for (auto const& tweakedFeatures :
|
|
{features - fixRemoveNFTokenAutoTrustLine,
|
|
features | fixRemoveNFTokenAutoTrustLine})
|
|
{
|
|
Env env{*this, tweakedFeatures};
|
|
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)};
|
|
|
|
// If the fixRemoveNFTokenAutoTrustLine amendment is active
|
|
// then this transaction fails.
|
|
{
|
|
TER const mintTER =
|
|
tweakedFeatures[fixRemoveNFTokenAutoTrustLine]
|
|
? static_cast<TER>(temINVALID_FLAG)
|
|
: static_cast<TER>(tesSUCCESS);
|
|
|
|
env(token::mint(alice, 0u),
|
|
token::xferFee(transferFee),
|
|
txflags(tfTransferable | tfTrustLine),
|
|
ter(mintTER));
|
|
env.close();
|
|
|
|
// If fixRemoveNFTokenAutoTrustLine is active the rest
|
|
// of this test falls on its face.
|
|
if (tweakedFeatures[fixRemoveNFTokenAutoTrustLine])
|
|
break;
|
|
}
|
|
// 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 preestablished, 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};
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
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 transfer 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 transfer 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.
|
|
for (auto NumberSwitchOver : {true})
|
|
{
|
|
if (NumberSwitchOver)
|
|
env.enableFeature(fixUniversalNumber);
|
|
else
|
|
env.disableFeature(fixUniversalNumber);
|
|
|
|
// 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 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) - baseFee;
|
|
minterBalance -= XRP(1) + baseFee;
|
|
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.
|
|
auto pmt = NumberSwitchOver ? drops(50000) : drops(99999);
|
|
STAmount carolBalance = env.balance(carol);
|
|
uint256 const minterSellOfferIndex =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
env(token::createOffer(minter, nftID, pmt), txflags(tfSellNFToken));
|
|
env.close();
|
|
env(token::acceptSellOffer(carol, minterSellOfferIndex));
|
|
env.close();
|
|
minterBalance += pmt - baseFee;
|
|
carolBalance -= pmt + baseFee;
|
|
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;
|
|
pmt = NumberSwitchOver ? drops(50001) : drops(100000);
|
|
env(token::createOffer(becky, nftID, pmt), token::owner(carol));
|
|
env.close();
|
|
env(token::acceptBuyOffer(carol, beckyBuyOfferIndex));
|
|
env.close();
|
|
carolBalance += pmt - drops(1) - baseFee;
|
|
beckyBalance -= pmt + baseFee;
|
|
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<std::uint32_t>();
|
|
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(
|
|
env,
|
|
alice,
|
|
taxon,
|
|
rand_int<std::uint32_t>(),
|
|
rand_int<std::uint16_t>(),
|
|
rand_int<std::uint16_t>());
|
|
check(taxon, nftAliceID);
|
|
|
|
uint256 const nftBeckyID = token::getID(
|
|
env,
|
|
becky,
|
|
taxon,
|
|
rand_int<std::uint32_t>(),
|
|
rand_int<std::uint16_t>(),
|
|
rand_int<std::uint16_t>());
|
|
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<Entry> entries;
|
|
entries.reserve(100);
|
|
for (std::size_t i = 0; i < 100; ++i)
|
|
entries.emplace_back(randURI(), rand_int<std::uint32_t>());
|
|
|
|
// 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<Json::Value> 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 canceling 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));
|
|
|
|
uint256 const offerIssuerToMinter =
|
|
keylet::nftoffer(issuer, env.seq(issuer)).key;
|
|
env(token::createOffer(issuer, nftokenID, drops(1)),
|
|
token::owner(minter),
|
|
token::destination(minter));
|
|
|
|
uint256 const offerIssuerToBuyer =
|
|
keylet::nftoffer(issuer, env.seq(issuer)).key;
|
|
env(token::createOffer(issuer, nftokenID, drops(1)),
|
|
token::owner(minter),
|
|
token::destination(buyer));
|
|
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 2);
|
|
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(token::cancelOffer(buyer, {offerIssuerToMinter}),
|
|
ter(tecNO_PERMISSION));
|
|
env(token::cancelOffer(minter, {offerIssuerToBuyer}),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 2);
|
|
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(token::cancelOffer(buyer, {offerIssuerToBuyer}));
|
|
env(token::cancelOffer(issuer, {offerIssuerToMinter}));
|
|
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 offerMinterSellsToBuyer =
|
|
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, offerMinterSellsToBuyer),
|
|
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, offerMinterSellsToBuyer));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 0);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 1);
|
|
}
|
|
|
|
// Test how adding a Destination field to a buy offer affects
|
|
// accepting that offer.
|
|
{
|
|
uint256 const offerMinterBuysFromBuyer =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::owner(buyer),
|
|
token::destination(buyer));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 1);
|
|
|
|
// issuer cannot accept a buy offer where they are the
|
|
// destination.
|
|
env(token::acceptBuyOffer(issuer, offerMinterBuysFromBuyer),
|
|
ter(tecNO_PERMISSION));
|
|
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, offerMinterBuysFromBuyer));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 0);
|
|
|
|
// If a destination other than the NFToken owner is set, that
|
|
// destination must act as a broker. The NFToken owner may not
|
|
// simply accept the offer.
|
|
uint256 const offerBuyerBuysFromMinter =
|
|
keylet::nftoffer(buyer, env.seq(buyer)).key;
|
|
env(token::createOffer(buyer, nftokenID, drops(1)),
|
|
token::owner(minter),
|
|
token::destination(broker));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 1);
|
|
|
|
env(token::acceptBuyOffer(minter, offerBuyerBuysFromMinter),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
|
|
// Clean up the unused offer.
|
|
env(token::cancelOffer(buyer, {offerBuyerBuysFromMinter}));
|
|
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(tecNO_PERMISSION));
|
|
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(tecNO_PERMISSION));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 2);
|
|
|
|
env(token::brokerOffers(
|
|
broker, offerMinterToBuyer, offerBuyerToMinter),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
|
|
// Buyer is successful with acceptOffer.
|
|
env(token::acceptBuyOffer(buyer, offerMinterToBuyer));
|
|
env.close();
|
|
|
|
// Clean out the unconsumed offer.
|
|
env(token::cancelOffer(buyer, {offerBuyerToMinter}));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 0);
|
|
|
|
// Clean out the unconsumed offer.
|
|
env(token::cancelOffer(issuer, {offerIssuerToBuyer}));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 0);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Show that if a buy and a sell offer both have the same destination,
|
|
// then that destination can broker the offers.
|
|
{
|
|
uint256 const offerMinterToBroker =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::destination(broker),
|
|
txflags(tfSellNFToken));
|
|
|
|
uint256 const offerBuyerToBroker =
|
|
keylet::nftoffer(buyer, env.seq(buyer)).key;
|
|
env(token::createOffer(buyer, nftokenID, drops(1)),
|
|
token::owner(minter),
|
|
token::destination(broker));
|
|
|
|
{
|
|
// Cannot broker offers when the sell destination is not the
|
|
// buyer or the broker.
|
|
env(token::brokerOffers(
|
|
issuer, offerBuyerToBroker, offerMinterToBroker),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 2);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 1);
|
|
}
|
|
|
|
// Broker is successful if they are the destination of both offers.
|
|
env(token::brokerOffers(
|
|
broker, offerBuyerToBroker, offerMinterToBroker));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 0);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 1);
|
|
}
|
|
}
|
|
|
|
void
|
|
testCreateOfferDestinationDisallowIncoming(FeatureBitset features)
|
|
{
|
|
testcase("Create offer destination disallow incoming");
|
|
|
|
using namespace test::jtx;
|
|
|
|
// test flag doesn't set unless amendment enabled
|
|
{
|
|
Env env{*this, features - disallowIncoming};
|
|
Account const alice{"alice"};
|
|
env.fund(XRP(10000), alice);
|
|
env(fset(alice, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
auto const sle = env.le(alice);
|
|
uint32_t flags = sle->getFlags();
|
|
BEAST_EXPECT(!(flags & lsfDisallowIncomingNFTokenOffer));
|
|
}
|
|
|
|
Env env{*this, features | disallowIncoming};
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const minter{"minter"};
|
|
Account const buyer{"buyer"};
|
|
Account const alice{"alice"};
|
|
|
|
env.fund(XRP(1000), issuer, minter, buyer, alice);
|
|
|
|
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();
|
|
|
|
// enable flag
|
|
env(fset(buyer, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
|
|
// a sell offer from the minter to the buyer should be rejected
|
|
{
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::destination(buyer),
|
|
txflags(tfSellNFToken),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 1);
|
|
BEAST_EXPECT(ownerCount(env, buyer) == 0);
|
|
}
|
|
|
|
// disable the flag
|
|
env(fclear(buyer, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
|
|
// create offer (allowed now) then cancel
|
|
{
|
|
uint256 const offerIndex =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::destination(buyer),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
env(token::cancelOffer(minter, {offerIndex}));
|
|
env.close();
|
|
}
|
|
|
|
// create offer, enable flag, then cancel
|
|
{
|
|
uint256 const offerIndex =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::destination(buyer),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
env(fset(buyer, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
|
|
env(token::cancelOffer(minter, {offerIndex}));
|
|
env.close();
|
|
|
|
env(fclear(buyer, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
}
|
|
|
|
// create offer then transfer
|
|
{
|
|
uint256 const offerIndex =
|
|
keylet::nftoffer(minter, env.seq(minter)).key;
|
|
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::destination(buyer),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
env(token::acceptSellOffer(buyer, offerIndex));
|
|
env.close();
|
|
}
|
|
|
|
// buyer now owns the token
|
|
|
|
// enable flag again
|
|
env(fset(buyer, asfDisallowIncomingNFTokenOffer));
|
|
env.close();
|
|
|
|
// a random offer to buy the token
|
|
{
|
|
env(token::createOffer(alice, nftokenID, drops(1)),
|
|
token::owner(buyer),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
}
|
|
|
|
// minter offer to buy the token
|
|
{
|
|
env(token::createOffer(minter, nftokenID, drops(1)),
|
|
token::owner(buyer),
|
|
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
|
|
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<uint256> 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};
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
// 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<std::reference_wrapper<Account const>>
|
|
accounts,
|
|
int line) {
|
|
for (Account const& acct : accounts)
|
|
{
|
|
if (std::uint32_t ownerCount =
|
|
test::jtx::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 - baseFee);
|
|
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) - baseFee);
|
|
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 - baseFee);
|
|
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) - baseFee);
|
|
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(amount).
|
|
auto setXAUBalance =
|
|
[this, &gw, &gwXAU, &env](
|
|
std::initializer_list<std::reference_wrapper<Account const>>
|
|
accounts,
|
|
int amount,
|
|
int line) {
|
|
for (Account const& acct : accounts)
|
|
{
|
|
auto const xauAmt = gwXAU(amount);
|
|
auto const balance = env.balance(acct, gwXAU);
|
|
if (balance < xauAmt)
|
|
{
|
|
env(pay(gw, acct, xauAmt - balance));
|
|
env.close();
|
|
}
|
|
else if (balance > xauAmt)
|
|
{
|
|
env(pay(acct, gw, balance - xauAmt));
|
|
env.close();
|
|
}
|
|
if (env.balance(acct, gwXAU) != xauAmt)
|
|
{
|
|
std::stringstream ss;
|
|
ss << "Unable to set " << acct.human()
|
|
<< " account balance to gwXAU(" << amount << ")";
|
|
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({issuer, minter, buyer, broker}, 1000, __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({issuer, minter, buyer, broker}, 1000, __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({issuer, minter, buyer, broker}, 1000, __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();
|
|
}
|
|
// Broker has a balance less than the seller offer
|
|
{
|
|
checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__);
|
|
setXAUBalance({issuer, minter, buyer}, 1000, __LINE__);
|
|
setXAUBalance({broker}, 500, __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();
|
|
|
|
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(550));
|
|
|
|
// Burn the NFT so the next test starts with a clean state.
|
|
env(token::burn(buyer, nftID));
|
|
env.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
void
|
|
testNFTokenOfferOwner(FeatureBitset features)
|
|
{
|
|
// Verify the Owner field of an offer behaves as expected.
|
|
testcase("NFToken offer owner");
|
|
|
|
using namespace test::jtx;
|
|
|
|
Env env{*this, features};
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const buyer1{"buyer1"};
|
|
Account const buyer2{"buyer2"};
|
|
env.fund(XRP(10000), issuer, buyer1, buyer2);
|
|
env.close();
|
|
|
|
// issuer creates an NFT.
|
|
uint256 const nftId{token::getNextID(env, issuer, 0u, tfTransferable)};
|
|
env(token::mint(issuer, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// Prove that issuer now owns nftId.
|
|
BEAST_EXPECT(nftCount(env, issuer) == 1);
|
|
BEAST_EXPECT(nftCount(env, buyer1) == 0);
|
|
BEAST_EXPECT(nftCount(env, buyer2) == 0);
|
|
|
|
// Both buyer1 and buyer2 create buy offers for nftId.
|
|
uint256 const buyer1OfferIndex =
|
|
keylet::nftoffer(buyer1, env.seq(buyer1)).key;
|
|
env(token::createOffer(buyer1, nftId, XRP(100)), token::owner(issuer));
|
|
uint256 const buyer2OfferIndex =
|
|
keylet::nftoffer(buyer2, env.seq(buyer2)).key;
|
|
env(token::createOffer(buyer2, nftId, XRP(100)), token::owner(issuer));
|
|
env.close();
|
|
|
|
// Lambda that counts the number of buy offers for a given NFT.
|
|
auto nftBuyOfferCount = [&env](uint256 const& nftId) -> std::size_t {
|
|
// We know that in this case not very many offers will be
|
|
// returned, so we skip the marker stuff.
|
|
Json::Value params;
|
|
params[jss::nft_id] = to_string(nftId);
|
|
Json::Value buyOffers =
|
|
env.rpc("json", "nft_buy_offers", to_string(params));
|
|
|
|
if (buyOffers.isMember(jss::result) &&
|
|
buyOffers[jss::result].isMember(jss::offers))
|
|
return buyOffers[jss::result][jss::offers].size();
|
|
|
|
return 0;
|
|
};
|
|
|
|
// Show there are two buy offers for nftId.
|
|
BEAST_EXPECT(nftBuyOfferCount(nftId) == 2);
|
|
|
|
// issuer accepts buyer1's offer.
|
|
env(token::acceptBuyOffer(issuer, buyer1OfferIndex));
|
|
env.close();
|
|
|
|
// Prove that buyer1 now owns nftId.
|
|
BEAST_EXPECT(nftCount(env, issuer) == 0);
|
|
BEAST_EXPECT(nftCount(env, buyer1) == 1);
|
|
BEAST_EXPECT(nftCount(env, buyer2) == 0);
|
|
|
|
// buyer1's offer was consumed, but buyer2's offer is still in the
|
|
// ledger.
|
|
BEAST_EXPECT(nftBuyOfferCount(nftId) == 1);
|
|
|
|
// buyer1 can now accept buyer2's offer, even though buyer2's
|
|
// NFTokenCreateOffer transaction specified the NFT Owner as issuer.
|
|
env(token::acceptBuyOffer(buyer1, buyer2OfferIndex));
|
|
env.close();
|
|
|
|
// Prove that buyer2 now owns nftId.
|
|
BEAST_EXPECT(nftCount(env, issuer) == 0);
|
|
BEAST_EXPECT(nftCount(env, buyer1) == 0);
|
|
BEAST_EXPECT(nftCount(env, buyer2) == 1);
|
|
|
|
// All of the NFTokenOffers are now consumed.
|
|
BEAST_EXPECT(nftBuyOfferCount(nftId) == 0);
|
|
}
|
|
|
|
void
|
|
testNFTokenWithTickets(FeatureBitset features)
|
|
{
|
|
// 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 moment 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
|
|
testNftXxxOffers(FeatureBitset features)
|
|
{
|
|
testcase("nft_buy_offers and nft_sell_offers");
|
|
|
|
// The default limit on returned NFToken offers is 250, so we need
|
|
// to produce more than 250 offers of each kind in order to exercise
|
|
// the marker.
|
|
|
|
// Fortunately there's nothing in the rules that says an account
|
|
// can't hold more than one offer for the same NFT. So we only
|
|
// need two accounts to generate the necessary offers.
|
|
using namespace test::jtx;
|
|
|
|
Env env{*this, features};
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const buyer{"buyer"};
|
|
|
|
// A lot of offers requires a lot for reserve.
|
|
env.fund(XRP(1000000), issuer, buyer);
|
|
env.close();
|
|
|
|
// Create an NFT that we'll make offers for.
|
|
uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)};
|
|
env(token::mint(issuer, 0), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// A lambda that validates nft_XXX_offers query responses.
|
|
auto checkOffers = [this, &env, &nftID](
|
|
char const* request,
|
|
int expectCount,
|
|
int expectMarkerCount,
|
|
int line) {
|
|
int markerCount = 0;
|
|
Json::Value allOffers(Json::arrayValue);
|
|
std::string marker;
|
|
|
|
// The do/while collects results until no marker is returned.
|
|
do
|
|
{
|
|
Json::Value nftOffers = [&env, &nftID, &request, &marker]() {
|
|
Json::Value params;
|
|
params[jss::nft_id] = to_string(nftID);
|
|
|
|
if (!marker.empty())
|
|
params[jss::marker] = marker;
|
|
return env.rpc("json", request, to_string(params));
|
|
}();
|
|
|
|
// If there are no offers for the NFT we get an error
|
|
if (expectCount == 0)
|
|
{
|
|
if (expect(
|
|
nftOffers.isMember(jss::result),
|
|
"expected \"result\"",
|
|
__FILE__,
|
|
line))
|
|
{
|
|
if (expect(
|
|
nftOffers[jss::result].isMember(jss::error),
|
|
"expected \"error\"",
|
|
__FILE__,
|
|
line))
|
|
{
|
|
expect(
|
|
nftOffers[jss::result][jss::error].asString() ==
|
|
"objectNotFound",
|
|
"expected \"objectNotFound\"",
|
|
__FILE__,
|
|
line);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
marker.clear();
|
|
if (expect(
|
|
nftOffers.isMember(jss::result),
|
|
"expected \"result\"",
|
|
__FILE__,
|
|
line))
|
|
{
|
|
Json::Value& result = nftOffers[jss::result];
|
|
|
|
if (result.isMember(jss::marker))
|
|
{
|
|
++markerCount;
|
|
marker = result[jss::marker].asString();
|
|
}
|
|
|
|
if (expect(
|
|
result.isMember(jss::offers),
|
|
"expected \"offers\"",
|
|
__FILE__,
|
|
line))
|
|
{
|
|
Json::Value& someOffers = result[jss::offers];
|
|
for (std::size_t i = 0; i < someOffers.size(); ++i)
|
|
allOffers.append(someOffers[i]);
|
|
}
|
|
}
|
|
} while (!marker.empty());
|
|
|
|
// Verify the contents of allOffers makes sense.
|
|
expect(
|
|
allOffers.size() == expectCount,
|
|
"Unexpected returned offer count",
|
|
__FILE__,
|
|
line);
|
|
expect(
|
|
markerCount == expectMarkerCount,
|
|
"Unexpected marker count",
|
|
__FILE__,
|
|
line);
|
|
std::optional<int> globalFlags;
|
|
std::set<std::string> offerIndexes;
|
|
std::set<std::string> amounts;
|
|
for (Json::Value const& offer : allOffers)
|
|
{
|
|
// The flags on all found offers should be the same.
|
|
if (!globalFlags)
|
|
globalFlags = offer[jss::flags].asInt();
|
|
|
|
expect(
|
|
*globalFlags == offer[jss::flags].asInt(),
|
|
"Inconsistent flags returned",
|
|
__FILE__,
|
|
line);
|
|
|
|
// The test conditions should produce unique indexes and
|
|
// amounts for all offers.
|
|
offerIndexes.insert(offer[jss::nft_offer_index].asString());
|
|
amounts.insert(offer[jss::amount].asString());
|
|
}
|
|
|
|
expect(
|
|
offerIndexes.size() == expectCount,
|
|
"Duplicate indexes returned?",
|
|
__FILE__,
|
|
line);
|
|
expect(
|
|
amounts.size() == expectCount,
|
|
"Duplicate amounts returned?",
|
|
__FILE__,
|
|
line);
|
|
};
|
|
|
|
// There are no sell offers.
|
|
checkOffers("nft_sell_offers", 0, false, __LINE__);
|
|
|
|
// A lambda that generates sell offers.
|
|
STAmount sellPrice = XRP(0);
|
|
auto makeSellOffers =
|
|
[&env, &issuer, &nftID, &sellPrice](STAmount const& limit) {
|
|
// Save a little test time by not closing too often.
|
|
int offerCount = 0;
|
|
while (sellPrice < limit)
|
|
{
|
|
sellPrice += XRP(1);
|
|
env(token::createOffer(issuer, nftID, sellPrice),
|
|
txflags(tfSellNFToken));
|
|
if (++offerCount % 10 == 0)
|
|
env.close();
|
|
}
|
|
env.close();
|
|
};
|
|
|
|
// There is one sell offer.
|
|
makeSellOffers(XRP(1));
|
|
checkOffers("nft_sell_offers", 1, 0, __LINE__);
|
|
|
|
// There are 250 sell offers.
|
|
makeSellOffers(XRP(250));
|
|
checkOffers("nft_sell_offers", 250, 0, __LINE__);
|
|
|
|
// There are 251 sell offers.
|
|
makeSellOffers(XRP(251));
|
|
checkOffers("nft_sell_offers", 251, 1, __LINE__);
|
|
|
|
// There are 500 sell offers.
|
|
makeSellOffers(XRP(500));
|
|
checkOffers("nft_sell_offers", 500, 1, __LINE__);
|
|
|
|
// There are 501 sell offers.
|
|
makeSellOffers(XRP(501));
|
|
checkOffers("nft_sell_offers", 501, 2, __LINE__);
|
|
|
|
// There are no buy offers.
|
|
checkOffers("nft_buy_offers", 0, 0, __LINE__);
|
|
|
|
// A lambda that generates buy offers.
|
|
STAmount buyPrice = XRP(0);
|
|
auto makeBuyOffers =
|
|
[&env, &buyer, &issuer, &nftID, &buyPrice](STAmount const& limit) {
|
|
// Save a little test time by not closing too often.
|
|
int offerCount = 0;
|
|
while (buyPrice < limit)
|
|
{
|
|
buyPrice += XRP(1);
|
|
env(token::createOffer(buyer, nftID, buyPrice),
|
|
token::owner(issuer));
|
|
if (++offerCount % 10 == 0)
|
|
env.close();
|
|
}
|
|
env.close();
|
|
};
|
|
|
|
// There is one buy offer;
|
|
makeBuyOffers(XRP(1));
|
|
checkOffers("nft_buy_offers", 1, 0, __LINE__);
|
|
|
|
// There are 250 buy offers.
|
|
makeBuyOffers(XRP(250));
|
|
checkOffers("nft_buy_offers", 250, 0, __LINE__);
|
|
|
|
// There are 251 buy offers.
|
|
makeBuyOffers(XRP(251));
|
|
checkOffers("nft_buy_offers", 251, 1, __LINE__);
|
|
|
|
// There are 500 buy offers.
|
|
makeBuyOffers(XRP(500));
|
|
checkOffers("nft_buy_offers", 500, 1, __LINE__);
|
|
|
|
// There are 501 buy offers.
|
|
makeBuyOffers(XRP(501));
|
|
checkOffers("nft_buy_offers", 501, 2, __LINE__);
|
|
}
|
|
|
|
void
|
|
testNFTokenNegOffer(FeatureBitset features)
|
|
{
|
|
using namespace test::jtx;
|
|
|
|
testcase("NFTokenNegOffer");
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const buyer{"buyer"};
|
|
Account const gw{"gw"};
|
|
IOU const gwXAU(gw["XAU"]);
|
|
|
|
{
|
|
Env env{*this, features};
|
|
|
|
env.fund(XRP(1000000), issuer, buyer, gw);
|
|
env.close();
|
|
|
|
env(trust(issuer, gwXAU(2000)));
|
|
env(trust(buyer, gwXAU(2000)));
|
|
env.close();
|
|
|
|
env(pay(gw, issuer, gwXAU(1000)));
|
|
env(pay(gw, buyer, gwXAU(1000)));
|
|
env.close();
|
|
|
|
// Create an NFT that we'll make XRP offers for.
|
|
uint256 const nftID0{
|
|
token::getNextID(env, issuer, 0u, tfTransferable)};
|
|
env(token::mint(issuer, 0), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// Create an NFT that we'll make IOU offers for.
|
|
uint256 const nftID1{
|
|
token::getNextID(env, issuer, 1u, tfTransferable)};
|
|
env(token::mint(issuer, 1), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
TER const offerCreateTER = temBAD_AMOUNT;
|
|
|
|
// Make offers with negative amounts for the NFTs
|
|
uint256 const sellNegXrpOfferIndex =
|
|
keylet::nftoffer(issuer, env.seq(issuer)).key;
|
|
env(token::createOffer(issuer, nftID0, XRP(-2)),
|
|
txflags(tfSellNFToken),
|
|
ter(offerCreateTER));
|
|
env.close();
|
|
|
|
uint256 const sellNegIouOfferIndex =
|
|
keylet::nftoffer(issuer, env.seq(issuer)).key;
|
|
env(token::createOffer(issuer, nftID1, gwXAU(-2)),
|
|
txflags(tfSellNFToken),
|
|
ter(offerCreateTER));
|
|
env.close();
|
|
|
|
uint256 const buyNegXrpOfferIndex =
|
|
keylet::nftoffer(buyer, env.seq(buyer)).key;
|
|
env(token::createOffer(buyer, nftID0, XRP(-1)),
|
|
token::owner(issuer),
|
|
ter(offerCreateTER));
|
|
env.close();
|
|
|
|
uint256 const buyNegIouOfferIndex =
|
|
keylet::nftoffer(buyer, env.seq(buyer)).key;
|
|
env(token::createOffer(buyer, nftID1, gwXAU(-1)),
|
|
token::owner(issuer),
|
|
ter(offerCreateTER));
|
|
env.close();
|
|
|
|
{
|
|
// Now try to accept the offers.
|
|
TER const offerAcceptTER = tecOBJECT_NOT_FOUND;
|
|
|
|
// Sell offers.
|
|
env(token::acceptSellOffer(buyer, sellNegXrpOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
env(token::acceptSellOffer(buyer, sellNegIouOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
|
|
// Buy offers.
|
|
env(token::acceptBuyOffer(issuer, buyNegXrpOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
env(token::acceptBuyOffer(issuer, buyNegIouOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
}
|
|
{
|
|
TER const offerAcceptTER = tecOBJECT_NOT_FOUND;
|
|
|
|
// Brokered offers.
|
|
env(token::brokerOffers(
|
|
gw, buyNegXrpOfferIndex, sellNegXrpOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
env(token::brokerOffers(
|
|
gw, buyNegIouOfferIndex, sellNegIouOfferIndex),
|
|
ter(offerAcceptTER));
|
|
env.close();
|
|
}
|
|
}
|
|
|
|
{
|
|
// Test buy offers with a destination.
|
|
Env env{*this, features};
|
|
|
|
env.fund(XRP(1000000), issuer, buyer);
|
|
|
|
// Create an NFT that we'll make offers for.
|
|
uint256 const nftID{
|
|
token::getNextID(env, issuer, 0u, tfTransferable)};
|
|
env(token::mint(issuer, 0), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
TER const offerCreateTER = tesSUCCESS;
|
|
|
|
env(token::createOffer(buyer, nftID, drops(1)),
|
|
token::owner(issuer),
|
|
token::destination(issuer),
|
|
ter(offerCreateTER));
|
|
env.close();
|
|
}
|
|
}
|
|
|
|
void
|
|
testIOUWithTransferFee(FeatureBitset features)
|
|
{
|
|
using namespace test::jtx;
|
|
|
|
testcase("Payments with IOU transfer fees");
|
|
|
|
{
|
|
Env env{*this, features};
|
|
|
|
Account const minter{"minter"};
|
|
Account const secondarySeller{"seller"};
|
|
Account const buyer{"buyer"};
|
|
Account const gw{"gateway"};
|
|
Account const broker{"broker"};
|
|
IOU const gwXAU(gw["XAU"]);
|
|
IOU const gwXPB(gw["XPB"]);
|
|
|
|
env.fund(XRP(1000), gw, minter, secondarySeller, buyer, broker);
|
|
env.close();
|
|
|
|
env(trust(minter, gwXAU(2000)));
|
|
env(trust(secondarySeller, gwXAU(2000)));
|
|
env(trust(broker, gwXAU(10000)));
|
|
env(trust(buyer, gwXAU(2000)));
|
|
env(trust(buyer, gwXPB(2000)));
|
|
env.close();
|
|
|
|
// The IOU issuer has a 2% transfer rate
|
|
env(rate(gw, 1.02));
|
|
env.close();
|
|
|
|
auto expectInitialState = [this,
|
|
&env,
|
|
&buyer,
|
|
&minter,
|
|
&secondarySeller,
|
|
&broker,
|
|
&gw,
|
|
&gwXAU,
|
|
&gwXPB]() {
|
|
// Buyer should have XAU 1000, XPB 0
|
|
// Minter should have XAU 0, XPB 0
|
|
// Secondary seller should have XAU 0, XPB 0
|
|
// Broker should have XAU 5000, XPB 0
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(1000));
|
|
BEAST_EXPECT(env.balance(buyer, gwXPB) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(minter, gwXPB) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(secondarySeller, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(secondarySeller, gwXPB) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(5000));
|
|
BEAST_EXPECT(env.balance(broker, gwXPB) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-1000));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XPB"]) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(gw, minter["XPB"]) == gwXPB(0));
|
|
BEAST_EXPECT(
|
|
env.balance(gw, secondarySeller["XAU"]) == gwXAU(0));
|
|
BEAST_EXPECT(
|
|
env.balance(gw, secondarySeller["XPB"]) == gwXPB(0));
|
|
BEAST_EXPECT(env.balance(gw, broker["XAU"]) == gwXAU(-5000));
|
|
BEAST_EXPECT(env.balance(gw, broker["XPB"]) == gwXPB(0));
|
|
};
|
|
|
|
auto reinitializeTrustLineBalances = [&expectInitialState,
|
|
&env,
|
|
&buyer,
|
|
&minter,
|
|
&secondarySeller,
|
|
&broker,
|
|
&gw,
|
|
&gwXAU,
|
|
&gwXPB]() {
|
|
if (auto const difference =
|
|
gwXAU(1000) - env.balance(buyer, gwXAU);
|
|
difference > gwXAU(0))
|
|
env(pay(gw, buyer, difference));
|
|
if (env.balance(buyer, gwXPB) > gwXPB(0))
|
|
env(pay(buyer, gw, env.balance(buyer, gwXPB)));
|
|
if (env.balance(minter, gwXAU) > gwXAU(0))
|
|
env(pay(minter, gw, env.balance(minter, gwXAU)));
|
|
if (env.balance(minter, gwXPB) > gwXPB(0))
|
|
env(pay(minter, gw, env.balance(minter, gwXPB)));
|
|
if (env.balance(secondarySeller, gwXAU) > gwXAU(0))
|
|
env(
|
|
pay(secondarySeller,
|
|
gw,
|
|
env.balance(secondarySeller, gwXAU)));
|
|
if (env.balance(secondarySeller, gwXPB) > gwXPB(0))
|
|
env(
|
|
pay(secondarySeller,
|
|
gw,
|
|
env.balance(secondarySeller, gwXPB)));
|
|
auto brokerDiff = gwXAU(5000) - env.balance(broker, gwXAU);
|
|
if (brokerDiff > gwXAU(0))
|
|
env(pay(gw, broker, brokerDiff));
|
|
else if (brokerDiff < gwXAU(0))
|
|
{
|
|
brokerDiff.negate();
|
|
env(pay(broker, gw, brokerDiff));
|
|
}
|
|
if (env.balance(broker, gwXPB) > gwXPB(0))
|
|
env(pay(broker, gw, env.balance(broker, gwXPB)));
|
|
env.close();
|
|
expectInitialState();
|
|
};
|
|
|
|
auto mintNFT = [&env](Account const& minter, int transferFee = 0) {
|
|
uint256 const nftID = token::getNextID(
|
|
env, minter, 0, tfTransferable, transferFee);
|
|
env(token::mint(minter),
|
|
token::xferFee(transferFee),
|
|
txflags(tfTransferable));
|
|
env.close();
|
|
return nftID;
|
|
};
|
|
|
|
auto createBuyOffer =
|
|
[&env](
|
|
Account const& offerer,
|
|
Account const& owner,
|
|
uint256 const& nftID,
|
|
STAmount const& amount,
|
|
std::optional<TER const> const terCode = {}) {
|
|
uint256 const offerID =
|
|
keylet::nftoffer(offerer, env.seq(offerer)).key;
|
|
env(token::createOffer(offerer, nftID, amount),
|
|
token::owner(owner),
|
|
terCode ? ter(*terCode)
|
|
: ter(static_cast<TER>(tesSUCCESS)));
|
|
env.close();
|
|
return offerID;
|
|
};
|
|
|
|
auto createSellOffer =
|
|
[&env](
|
|
Account const& offerer,
|
|
uint256 const& nftID,
|
|
STAmount const& amount,
|
|
std::optional<TER const> const terCode = {}) {
|
|
uint256 const offerID =
|
|
keylet::nftoffer(offerer, env.seq(offerer)).key;
|
|
env(token::createOffer(offerer, nftID, amount),
|
|
txflags(tfSellNFToken),
|
|
terCode ? ter(*terCode)
|
|
: ter(static_cast<TER>(tesSUCCESS)));
|
|
env.close();
|
|
return offerID;
|
|
};
|
|
|
|
{
|
|
// Buyer attempts to send 100% of their balance of an IOU
|
|
// (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createSellOffer(minter, nftID, gwXAU(1000));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptSellOffer(buyer, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Buyer attempts to send 100% of their balance of an IOU
|
|
// (buyside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, minter, nftID, gwXAU(1000));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptBuyOffer(minter, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU, but such that the addition of the transfer
|
|
// fee would be greater than the buyer's balance (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID = createSellOffer(minter, nftID, gwXAU(995));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptSellOffer(buyer, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU, but such that the addition of the transfer
|
|
// fee would be greater than the buyer's balance (buyside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, minter, nftID, gwXAU(995));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptBuyOffer(minter, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU with a transfer fee, and such that the
|
|
// addition of the transfer fee is still less than their balance
|
|
// (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID = createSellOffer(minter, nftID, gwXAU(900));
|
|
env(token::acceptSellOffer(buyer, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(82));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-900));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-82));
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU with a transfer fee, and such that the
|
|
// addition of the transfer fee is still less than their balance
|
|
// (buyside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, minter, nftID, gwXAU(900));
|
|
env(token::acceptBuyOffer(minter, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(82));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-900));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-82));
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU with a transfer fee, and such that the
|
|
// addition of the transfer fee is equal than their balance
|
|
// (sellside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// pay them an additional XAU 20 to cover transfer rate
|
|
env(pay(gw, buyer, gwXAU(20)));
|
|
env.close();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createSellOffer(minter, nftID, gwXAU(1000));
|
|
env(token::acceptSellOffer(buyer, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-1000));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(0));
|
|
}
|
|
{
|
|
// Buyer attempts to send an amount less than 100% of their
|
|
// balance of an IOU with a transfer fee, and such that the
|
|
// addition of the transfer fee is equal than their balance
|
|
// (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// pay them an additional XAU 20 to cover transfer rate
|
|
env(pay(gw, buyer, gwXAU(20)));
|
|
env.close();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, minter, nftID, gwXAU(1000));
|
|
env(token::acceptBuyOffer(minter, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-1000));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(0));
|
|
}
|
|
{
|
|
// Gateway attempts to buy NFT with their own IOU - no
|
|
// transfer fee is calculated here (sellside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createSellOffer(minter, nftID, gwXAU(1000));
|
|
TER const sellTER = tesSUCCESS;
|
|
env(token::acceptSellOffer(gw, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-1000));
|
|
}
|
|
{
|
|
// Gateway attempts to buy NFT with their own IOU - no
|
|
// transfer fee is calculated here (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
TER const offerTER = tesSUCCESS;
|
|
auto const offerID =
|
|
createBuyOffer(gw, minter, nftID, gwXAU(1000), {offerTER});
|
|
TER const sellTER = tesSUCCESS;
|
|
env(token::acceptBuyOffer(minter, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-1000));
|
|
}
|
|
{
|
|
// Gateway attempts to buy NFT with their own IOU for more
|
|
// than minter trusts (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createSellOffer(minter, nftID, gwXAU(5000));
|
|
TER const sellTER = tesSUCCESS;
|
|
env(token::acceptSellOffer(gw, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(5000));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-5000));
|
|
}
|
|
{
|
|
// Gateway attempts to buy NFT with their own IOU for more
|
|
// than minter trusts (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
TER const offerTER = tesSUCCESS;
|
|
auto const offerID =
|
|
createBuyOffer(gw, minter, nftID, gwXAU(5000), {offerTER});
|
|
TER const sellTER = tesSUCCESS;
|
|
env(token::acceptBuyOffer(minter, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(5000));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-5000));
|
|
}
|
|
{
|
|
// Gateway is the NFT minter and attempts to sell NFT for an
|
|
// amount that would be greater than a balance if there were a
|
|
// transfer fee calculated in this transaction. (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(gw);
|
|
auto const offerID = createSellOffer(gw, nftID, gwXAU(1000));
|
|
env(token::acceptSellOffer(buyer, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(0));
|
|
}
|
|
{
|
|
// Gateway is the NFT minter and attempts to sell NFT for an
|
|
// amount that would be greater than a balance if there were a
|
|
// transfer fee calculated in this transaction. (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
auto const nftID = mintNFT(gw);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, gw, nftID, gwXAU(1000));
|
|
env(token::acceptBuyOffer(gw, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(0));
|
|
}
|
|
{
|
|
// Gateway is the NFT minter and attempts to sell NFT for an
|
|
// amount that is greater than a balance before transfer fees.
|
|
// (sellside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(gw);
|
|
auto const offerID = createSellOffer(gw, nftID, gwXAU(2000));
|
|
env(token::acceptSellOffer(buyer, offerID),
|
|
ter(static_cast<TER>(tecINSUFFICIENT_FUNDS)));
|
|
env.close();
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Gateway is the NFT minter and attempts to sell NFT for an
|
|
// amount that is greater than a balance before transfer fees.
|
|
// (buyside)
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(gw);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, gw, nftID, gwXAU(2000));
|
|
env(token::acceptBuyOffer(gw, offerID),
|
|
ter(static_cast<TER>(tecINSUFFICIENT_FUNDS)));
|
|
env.close();
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Minter attempts to sell the token for XPB 10, which they
|
|
// have no trust line for and buyer has none of (sellside).
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID = createSellOffer(minter, nftID, gwXPB(10));
|
|
env(token::acceptSellOffer(buyer, offerID),
|
|
ter(static_cast<TER>(tecINSUFFICIENT_FUNDS)));
|
|
env.close();
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Minter attempts to sell the token for XPB 10, which they
|
|
// have no trust line for and buyer has none of (buyside).
|
|
reinitializeTrustLineBalances();
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID = createBuyOffer(
|
|
buyer,
|
|
minter,
|
|
nftID,
|
|
gwXPB(10),
|
|
{static_cast<TER>(tecUNFUNDED_OFFER)});
|
|
env(token::acceptBuyOffer(minter, offerID),
|
|
ter(static_cast<TER>(tecOBJECT_NOT_FOUND)));
|
|
env.close();
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// Minter attempts to sell the token for XPB 10 and the buyer
|
|
// has it but the minter has no trust line. Trust line is
|
|
// created as a result of the tx (sellside).
|
|
reinitializeTrustLineBalances();
|
|
env(pay(gw, buyer, gwXPB(100)));
|
|
env.close();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID = createSellOffer(minter, nftID, gwXPB(10));
|
|
env(token::acceptSellOffer(buyer, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXPB) == gwXPB(10));
|
|
BEAST_EXPECT(env.balance(buyer, gwXPB) == gwXPB(89.8));
|
|
BEAST_EXPECT(env.balance(gw, minter["XPB"]) == gwXPB(-10));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XPB"]) == gwXPB(-89.8));
|
|
}
|
|
{
|
|
// Minter attempts to sell the token for XPB 10 and the buyer
|
|
// has it but the minter has no trust line. Trust line is
|
|
// created as a result of the tx (buyside).
|
|
reinitializeTrustLineBalances();
|
|
env(pay(gw, buyer, gwXPB(100)));
|
|
env.close();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const offerID =
|
|
createBuyOffer(buyer, minter, nftID, gwXPB(10));
|
|
env(token::acceptBuyOffer(minter, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXPB) == gwXPB(10));
|
|
BEAST_EXPECT(env.balance(buyer, gwXPB) == gwXPB(89.8));
|
|
BEAST_EXPECT(env.balance(gw, minter["XPB"]) == gwXPB(-10));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XPB"]) == gwXPB(-89.8));
|
|
}
|
|
{
|
|
// There is a transfer fee on the NFT and buyer has exact
|
|
// amount (sellside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// secondarySeller has to sell it because transfer fees only
|
|
// happen on secondary sales
|
|
auto const nftID = mintNFT(minter, 3000); // 3%
|
|
auto const primaryOfferID =
|
|
createSellOffer(minter, nftID, XRP(0));
|
|
env(token::acceptSellOffer(secondarySeller, primaryOfferID));
|
|
env.close();
|
|
|
|
// now we can do a secondary sale
|
|
auto const offerID =
|
|
createSellOffer(secondarySeller, nftID, gwXAU(1000));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptSellOffer(buyer, offerID), ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// There is a transfer fee on the NFT and buyer has exact
|
|
// amount (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// secondarySeller has to sell it because transfer fees only
|
|
// happen on secondary sales
|
|
auto const nftID = mintNFT(minter, 3000); // 3%
|
|
auto const primaryOfferID =
|
|
createSellOffer(minter, nftID, XRP(0));
|
|
env(token::acceptSellOffer(secondarySeller, primaryOfferID));
|
|
env.close();
|
|
|
|
// now we can do a secondary sale
|
|
auto const offerID =
|
|
createBuyOffer(buyer, secondarySeller, nftID, gwXAU(1000));
|
|
TER const sellTER = tecINSUFFICIENT_FUNDS;
|
|
env(token::acceptBuyOffer(secondarySeller, offerID),
|
|
ter(sellTER));
|
|
env.close();
|
|
|
|
expectInitialState();
|
|
}
|
|
{
|
|
// There is a transfer fee on the NFT and buyer has enough
|
|
// (sellside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// secondarySeller has to sell it because transfer fees only
|
|
// happen on secondary sales
|
|
auto const nftID = mintNFT(minter, 3000); // 3%
|
|
auto const primaryOfferID =
|
|
createSellOffer(minter, nftID, XRP(0));
|
|
env(token::acceptSellOffer(secondarySeller, primaryOfferID));
|
|
env.close();
|
|
|
|
// now we can do a secondary sale
|
|
auto const offerID =
|
|
createSellOffer(secondarySeller, nftID, gwXAU(900));
|
|
env(token::acceptSellOffer(buyer, offerID));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(27));
|
|
BEAST_EXPECT(env.balance(secondarySeller, gwXAU) == gwXAU(873));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(82));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-27));
|
|
BEAST_EXPECT(
|
|
env.balance(gw, secondarySeller["XAU"]) == gwXAU(-873));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-82));
|
|
}
|
|
{
|
|
// There is a transfer fee on the NFT and buyer has enough
|
|
// (buyside)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// secondarySeller has to sell it because transfer fees only
|
|
// happen on secondary sales
|
|
auto const nftID = mintNFT(minter, 3000); // 3%
|
|
auto const primaryOfferID =
|
|
createSellOffer(minter, nftID, XRP(0));
|
|
env(token::acceptSellOffer(secondarySeller, primaryOfferID));
|
|
env.close();
|
|
|
|
// now we can do a secondary sale
|
|
auto const offerID =
|
|
createBuyOffer(buyer, secondarySeller, nftID, gwXAU(900));
|
|
env(token::acceptBuyOffer(secondarySeller, offerID));
|
|
env.close();
|
|
|
|
// receives 3% of 900 - 27
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(27));
|
|
// receives 97% of 900 - 873
|
|
BEAST_EXPECT(env.balance(secondarySeller, gwXAU) == gwXAU(873));
|
|
// pays 900 plus 2% transfer fee on XAU - 918
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(82));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-27));
|
|
BEAST_EXPECT(
|
|
env.balance(gw, secondarySeller["XAU"]) == gwXAU(-873));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-82));
|
|
}
|
|
{
|
|
// There is a broker fee on the NFT. XAU transfer fee is only
|
|
// calculated from the buyer's output, not deducted from
|
|
// broker fee.
|
|
//
|
|
// For a payment of 500 with a 2% IOU transfee fee and 100
|
|
// broker fee:
|
|
//
|
|
// A) Total sale amount + IOU transfer fee is paid by buyer
|
|
// (Buyer pays (1.02 * 500) = 510)
|
|
// B) GW receives the additional IOU transfer fee
|
|
// (GW receives 10 from buyer calculated above)
|
|
// C) Broker receives broker fee (no IOU transfer fee)
|
|
// (Broker receives 100 from buyer)
|
|
// D) Seller receives balance (no IOU transfer fee)
|
|
// (Seller receives (510 - 10 - 100) = 400)
|
|
reinitializeTrustLineBalances();
|
|
|
|
auto const nftID = mintNFT(minter);
|
|
auto const sellOffer =
|
|
createSellOffer(minter, nftID, gwXAU(300));
|
|
auto const buyOffer =
|
|
createBuyOffer(buyer, minter, nftID, gwXAU(500));
|
|
env(token::brokerOffers(broker, buyOffer, sellOffer),
|
|
token::brokerFee(gwXAU(100)));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(400));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(490));
|
|
BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(5100));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-400));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-490));
|
|
BEAST_EXPECT(env.balance(gw, broker["XAU"]) == gwXAU(-5100));
|
|
}
|
|
{
|
|
// There is broker and transfer fee on the NFT
|
|
//
|
|
// For a payment of 500 with a 2% IOU transfer fee, 3% NFT
|
|
// transfer fee, and 100 broker fee:
|
|
//
|
|
// A) Total sale amount + IOU transfer fee is paid by buyer
|
|
// (Buyer pays (1.02 * 500) = 510)
|
|
// B) GW receives the additional IOU transfer fee
|
|
// (GW receives 10 from buyer calculated above)
|
|
// C) Broker receives broker fee (no IOU transfer fee)
|
|
// (Broker receives 100 from buyer)
|
|
// D) Minter receives transfer fee (no IOU transfer fee)
|
|
// (Minter receives 0.03 * (510 - 10 - 100) = 12)
|
|
// E) Seller receives balance (no IOU transfer fee)
|
|
// (Seller receives (510 - 10 - 100 - 12) = 388)
|
|
reinitializeTrustLineBalances();
|
|
|
|
// secondarySeller has to sell it because transfer fees only
|
|
// happen on secondary sales
|
|
auto const nftID = mintNFT(minter, 3000); // 3%
|
|
auto const primaryOfferID =
|
|
createSellOffer(minter, nftID, XRP(0));
|
|
env(token::acceptSellOffer(secondarySeller, primaryOfferID));
|
|
env.close();
|
|
|
|
// now we can do a secondary sale
|
|
auto const sellOffer =
|
|
createSellOffer(secondarySeller, nftID, gwXAU(300));
|
|
auto const buyOffer =
|
|
createBuyOffer(buyer, secondarySeller, nftID, gwXAU(500));
|
|
env(token::brokerOffers(broker, buyOffer, sellOffer),
|
|
token::brokerFee(gwXAU(100)));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(12));
|
|
BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(490));
|
|
BEAST_EXPECT(env.balance(secondarySeller, gwXAU) == gwXAU(388));
|
|
BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(5100));
|
|
BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-12));
|
|
BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(-490));
|
|
BEAST_EXPECT(
|
|
env.balance(gw, secondarySeller["XAU"]) == gwXAU(-388));
|
|
BEAST_EXPECT(env.balance(gw, broker["XAU"]) == gwXAU(-5100));
|
|
}
|
|
}
|
|
}
|
|
|
|
void
|
|
testBrokeredSaleToSelf(FeatureBitset features)
|
|
{
|
|
// There was a bug that if an account had...
|
|
//
|
|
// 1. An NFToken, and
|
|
// 2. An offer on the ledger to buy that same token, and
|
|
// 3. Also an offer of the ledger to sell that same token,
|
|
//
|
|
// Then someone could broker the two offers. This would result in
|
|
// the NFToken being bought and returned to the original owner and
|
|
// the broker pocketing the profit.
|
|
//
|
|
testcase("Brokered sale to self");
|
|
|
|
using namespace test::jtx;
|
|
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const broker{"broker"};
|
|
|
|
Env env{*this, features};
|
|
auto const baseFee = env.current()->fees().base;
|
|
env.fund(XRP(10000), alice, bob, broker);
|
|
env.close();
|
|
|
|
// For this scenario to occur we need the following steps:
|
|
//
|
|
// 1. alice mints NFT.
|
|
// 2. bob creates a buy offer for it for 5 XRP.
|
|
// 3. alice decides to gift the NFT to bob for 0.
|
|
// creating a sell offer (hopefully using a destination too)
|
|
// 4. Bob accepts the sell offer, because it is better than
|
|
// paying 5 XRP.
|
|
// 5. At this point, bob has the NFT and still has their buy
|
|
// offer from when they did not have the NFT! This is because
|
|
// the order book is not cleared when an NFT changes hands.
|
|
// 6. Now that Bob owns the NFT, he cannot create new buy offers.
|
|
// However he still has one left over from when he did not own
|
|
// it. He can create new sell offers and does.
|
|
// 7. Now that bob has both a buy and a sell offer for the same NFT,
|
|
// a broker can sell the NFT that bob owns to bob and pocket the
|
|
// difference.
|
|
uint256 const nftId{token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// Bob creates a buy offer for 5 XRP. Alice creates a sell offer
|
|
// for 0 XRP.
|
|
uint256 const bobBuyOfferIndex =
|
|
keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId, XRP(5)), token::owner(alice));
|
|
|
|
uint256 const aliceSellOfferIndex =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId, XRP(0)),
|
|
token::destination(bob),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
// bob accepts alice's offer but forgets to remove the old buy offer.
|
|
env(token::acceptSellOffer(bob, aliceSellOfferIndex));
|
|
env.close();
|
|
|
|
// Note that bob still has a buy offer on the books.
|
|
BEAST_EXPECT(env.le(keylet::nftoffer(bobBuyOfferIndex)));
|
|
|
|
// Bob creates a sell offer for the gift NFT from alice.
|
|
uint256 const bobSellOfferIndex =
|
|
keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId, XRP(4)), txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
// bob now has a buy offer and a sell offer on the books. A broker
|
|
// spots this and swoops in to make a profit.
|
|
BEAST_EXPECT(nftCount(env, bob) == 1);
|
|
auto const bobsPriorBalance = env.balance(bob);
|
|
auto const brokersPriorBalance = env.balance(broker);
|
|
env(token::brokerOffers(broker, bobBuyOfferIndex, bobSellOfferIndex),
|
|
token::brokerFee(XRP(1)),
|
|
ter(tecCANT_ACCEPT_OWN_NFTOKEN_OFFER));
|
|
env.close();
|
|
|
|
// A tec result was returned, so no state should change other
|
|
// than the broker burning their transaction fee.
|
|
BEAST_EXPECT(nftCount(env, bob) == 1);
|
|
BEAST_EXPECT(env.balance(bob) == bobsPriorBalance);
|
|
BEAST_EXPECT(env.balance(broker) == brokersPriorBalance - baseFee);
|
|
}
|
|
|
|
void
|
|
testNFTokenRemint(FeatureBitset features)
|
|
{
|
|
using namespace test::jtx;
|
|
|
|
testcase("NFTokenRemint");
|
|
|
|
// Returns the current ledger sequence
|
|
auto openLedgerSeq = [](Env& env) { return env.current()->seq(); };
|
|
|
|
// Close the ledger until the ledger sequence is large enough to delete
|
|
// the account (no longer within <Sequence + 256>)
|
|
// This is enforced by the featureDeletableAccounts amendment
|
|
auto incLgrSeqForAcctDel = [&](Env& env, Account const& acct) {
|
|
int const delta = [&]() -> int {
|
|
if (env.seq(acct) + 255 > openLedgerSeq(env))
|
|
return env.seq(acct) - openLedgerSeq(env) + 255;
|
|
return 0;
|
|
}();
|
|
BEAST_EXPECT(delta >= 0);
|
|
for (int i = 0; i < delta; ++i)
|
|
env.close();
|
|
BEAST_EXPECT(openLedgerSeq(env) == env.seq(acct) + 255);
|
|
};
|
|
|
|
// Close the ledger until the ledger sequence is no longer
|
|
// within <FirstNFTokenSequence + MintedNFTokens + 256>.
|
|
auto incLgrSeqForFixNftRemint = [&](Env& env, Account const& acct) {
|
|
int delta = 0;
|
|
auto const deletableLgrSeq =
|
|
(*env.le(acct))[~sfFirstNFTokenSequence].value_or(0) +
|
|
(*env.le(acct))[sfMintedNFTokens] + 255;
|
|
|
|
if (deletableLgrSeq > openLedgerSeq(env))
|
|
delta = deletableLgrSeq - openLedgerSeq(env);
|
|
|
|
BEAST_EXPECT(delta >= 0);
|
|
for (int i = 0; i < delta; ++i)
|
|
env.close();
|
|
BEAST_EXPECT(openLedgerSeq(env) == deletableLgrSeq);
|
|
};
|
|
|
|
// We check if NFTokenIDs can be duplicated by
|
|
// re-creation of an account
|
|
{
|
|
Env env{*this, features};
|
|
Account const alice("alice");
|
|
Account const becky("becky");
|
|
|
|
env.fund(XRP(10000), alice, becky);
|
|
env.close();
|
|
|
|
// alice mint and burn a NFT
|
|
uint256 const prevNFTokenID = token::getNextID(env, alice, 0u);
|
|
env(token::mint(alice));
|
|
env.close();
|
|
env(token::burn(alice, prevNFTokenID));
|
|
env.close();
|
|
|
|
// alice has minted 1 NFToken
|
|
BEAST_EXPECT((*env.le(alice))[sfMintedNFTokens] == 1);
|
|
|
|
// Close enough ledgers to delete alice's account
|
|
incLgrSeqForAcctDel(env, alice);
|
|
|
|
// alice's account is deleted
|
|
Keylet const aliceAcctKey{keylet::account(alice.id())};
|
|
auto const acctDelFee{drops(env.current()->fees().increment)};
|
|
env(acctdelete(alice, becky), fee(acctDelFee));
|
|
env.close();
|
|
|
|
// alice's account root is gone from the most recently
|
|
// closed ledger and the current ledger.
|
|
BEAST_EXPECT(!env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(!env.current()->exists(aliceAcctKey));
|
|
|
|
// Fund alice to re-create her account
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// alice's account now exists and has minted 0 NFTokens
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
BEAST_EXPECT((*env.le(alice))[sfMintedNFTokens] == 0);
|
|
|
|
// alice mints a NFT with same params as prevNFTokenID
|
|
uint256 const remintNFTokenID = token::getNextID(env, alice, 0u);
|
|
env(token::mint(alice));
|
|
env.close();
|
|
|
|
// burn the NFT to make sure alice owns remintNFTokenID
|
|
env(token::burn(alice, remintNFTokenID));
|
|
env.close();
|
|
|
|
// Check that two NFTs don't have the same ID
|
|
BEAST_EXPECT(remintNFTokenID != prevNFTokenID);
|
|
}
|
|
|
|
// Test if the issuer account can be deleted after an authorized
|
|
// minter mints and burns a batch of NFTokens.
|
|
{
|
|
Env env{*this, features};
|
|
Account const alice("alice");
|
|
Account const becky("becky");
|
|
Account const minter{"minter"};
|
|
|
|
env.fund(XRP(10000), alice, becky, minter);
|
|
env.close();
|
|
|
|
// alice sets minter as her authorized minter
|
|
env(token::setMinter(alice, minter));
|
|
env.close();
|
|
|
|
// minter mints 500 NFTs for alice
|
|
std::vector<uint256> nftIDs;
|
|
nftIDs.reserve(500);
|
|
for (int i = 0; i < 500; i++)
|
|
{
|
|
uint256 const nftokenID = token::getNextID(env, alice, 0u);
|
|
nftIDs.push_back(nftokenID);
|
|
env(token::mint(minter), token::issuer(alice));
|
|
}
|
|
env.close();
|
|
|
|
// minter burns 500 NFTs
|
|
for (auto const nftokenID : nftIDs)
|
|
{
|
|
env(token::burn(minter, nftokenID));
|
|
}
|
|
env.close();
|
|
|
|
// Increment ledger sequence to the number that is
|
|
// enforced by the featureDeletableAccounts amendment
|
|
incLgrSeqForAcctDel(env, alice);
|
|
|
|
// Verify that alice's account root is present.
|
|
Keylet const aliceAcctKey{keylet::account(alice.id())};
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
auto const acctDelFee{drops(env.current()->fees().increment)};
|
|
|
|
// alice tries to delete her account, but is unsuccessful.
|
|
// Due to authorized minting, alice's account sequence does not
|
|
// advance while minter mints NFTokens for her.
|
|
// The new account deletion retriction <FirstNFTokenSequence +
|
|
// MintedNFTokens + 256> enabled by this amendment will enforce
|
|
// alice to wait for more ledgers to close before she can
|
|
// delete her account, to prevent duplicate NFTokenIDs
|
|
env(acctdelete(alice, becky), fee(acctDelFee), ter(tecTOO_SOON));
|
|
env.close();
|
|
|
|
// alice's account is still present
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
// Close more ledgers until it is no longer within
|
|
// <FirstNFTokenSequence + MintedNFTokens + 256>
|
|
// to be able to delete alice's account
|
|
incLgrSeqForFixNftRemint(env, alice);
|
|
|
|
// alice's account is deleted
|
|
env(acctdelete(alice, becky), fee(acctDelFee));
|
|
env.close();
|
|
|
|
// alice's account root is gone from the most recently
|
|
// closed ledger and the current ledger.
|
|
BEAST_EXPECT(!env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(!env.current()->exists(aliceAcctKey));
|
|
|
|
// Fund alice to re-create her account
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// alice's account now exists and has minted 0 NFTokens
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
BEAST_EXPECT((*env.le(alice))[sfMintedNFTokens] == 0);
|
|
|
|
// alice mints a NFT with same params as the first one before
|
|
// the account delete.
|
|
uint256 const remintNFTokenID = token::getNextID(env, alice, 0u);
|
|
env(token::mint(alice));
|
|
env.close();
|
|
|
|
// burn the NFT to make sure alice owns remintNFTokenID
|
|
env(token::burn(alice, remintNFTokenID));
|
|
env.close();
|
|
|
|
// The new NFT minted will not have the same ID
|
|
// as any of the NFTs authorized minter minted
|
|
BEAST_EXPECT(
|
|
std::find(nftIDs.begin(), nftIDs.end(), remintNFTokenID) ==
|
|
nftIDs.end());
|
|
}
|
|
|
|
// When an account mints and burns a batch of NFTokens using tickets,
|
|
// see if the account can be deleted.
|
|
{
|
|
Env env{*this, features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const becky{"becky"};
|
|
env.fund(XRP(10000), alice, becky);
|
|
env.close();
|
|
|
|
// alice grab enough tickets for all of the following
|
|
// transactions. Note that once the tickets are acquired alice's
|
|
// account sequence number should not advance.
|
|
std::uint32_t aliceTicketSeq{env.seq(alice) + 1};
|
|
env(ticket::create(alice, 100));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ticketCount(env, alice) == 100);
|
|
BEAST_EXPECT(ownerCount(env, alice) == 100);
|
|
|
|
// alice mints 50 NFTs using tickets
|
|
std::vector<uint256> nftIDs;
|
|
nftIDs.reserve(50);
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
nftIDs.push_back(token::getNextID(env, alice, 0u));
|
|
env(token::mint(alice, 0u), ticket::use(aliceTicketSeq++));
|
|
env.close();
|
|
}
|
|
|
|
// alice burns 50 NFTs using tickets
|
|
for (auto const nftokenID : nftIDs)
|
|
{
|
|
env(token::burn(alice, nftokenID),
|
|
ticket::use(aliceTicketSeq++));
|
|
}
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ticketCount(env, alice) == 0);
|
|
|
|
// Increment ledger sequence to the number that is
|
|
// enforced by the featureDeletableAccounts amendment
|
|
incLgrSeqForAcctDel(env, alice);
|
|
|
|
// Verify that alice's account root is present.
|
|
Keylet const aliceAcctKey{keylet::account(alice.id())};
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
auto const acctDelFee{drops(env.current()->fees().increment)};
|
|
|
|
// alice tries to delete her account, but is unsuccessful.
|
|
// Due to authorized minting, alice's account sequence does not
|
|
// advance while minter mints NFTokens for her using tickets.
|
|
// The new account deletion retriction <FirstNFTokenSequence +
|
|
// MintedNFTokens + 256> enabled by this amendment will enforce
|
|
// alice to wait for more ledgers to close before she can
|
|
// delete her account, to prevent duplicate NFTokenIDs
|
|
env(acctdelete(alice, becky), fee(acctDelFee), ter(tecTOO_SOON));
|
|
env.close();
|
|
|
|
// alice's account is still present
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
// Close more ledgers until it is no longer within
|
|
// <FirstNFTokenSequence + MintedNFTokens + 256>
|
|
// to be able to delete alice's account
|
|
incLgrSeqForFixNftRemint(env, alice);
|
|
|
|
// alice's account is deleted
|
|
env(acctdelete(alice, becky), fee(acctDelFee));
|
|
env.close();
|
|
|
|
// alice's account root is gone from the most recently
|
|
// closed ledger and the current ledger.
|
|
BEAST_EXPECT(!env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(!env.current()->exists(aliceAcctKey));
|
|
|
|
// Fund alice to re-create her account
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// alice's account now exists and has minted 0 NFTokens
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
BEAST_EXPECT((*env.le(alice))[sfMintedNFTokens] == 0);
|
|
|
|
// alice mints a NFT with same params as the first one before
|
|
// the account delete.
|
|
uint256 const remintNFTokenID = token::getNextID(env, alice, 0u);
|
|
env(token::mint(alice));
|
|
env.close();
|
|
|
|
// burn the NFT to make sure alice owns remintNFTokenID
|
|
env(token::burn(alice, remintNFTokenID));
|
|
env.close();
|
|
|
|
// The new NFT minted will not have the same ID
|
|
// as any of the NFTs authorized minter minted using tickets
|
|
BEAST_EXPECT(
|
|
std::find(nftIDs.begin(), nftIDs.end(), remintNFTokenID) ==
|
|
nftIDs.end());
|
|
}
|
|
// When an authorized minter mints and burns a batch of NFTokens using
|
|
// tickets, issuer's account needs to wait a longer time before it can
|
|
// be deleted.
|
|
// After the issuer's account is re-created and mints a NFT, it should
|
|
// not have the same NFTokenID as the ones authorized minter minted.
|
|
Env env{*this, features};
|
|
Account const alice("alice");
|
|
Account const becky("becky");
|
|
Account const minter{"minter"};
|
|
|
|
env.fund(XRP(10000), alice, becky, minter);
|
|
env.close();
|
|
|
|
// alice sets minter as her authorized minter
|
|
env(token::setMinter(alice, minter));
|
|
env.close();
|
|
|
|
// minter creates 100 tickets
|
|
std::uint32_t minterTicketSeq{env.seq(minter) + 1};
|
|
env(ticket::create(minter, 100));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ticketCount(env, minter) == 100);
|
|
BEAST_EXPECT(ownerCount(env, minter) == 100);
|
|
|
|
// minter mints 50 NFTs for alice using tickets
|
|
std::vector<uint256> nftIDs;
|
|
nftIDs.reserve(50);
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
uint256 const nftokenID = token::getNextID(env, alice, 0u);
|
|
nftIDs.push_back(nftokenID);
|
|
env(token::mint(minter),
|
|
token::issuer(alice),
|
|
ticket::use(minterTicketSeq++));
|
|
}
|
|
env.close();
|
|
|
|
// minter burns 50 NFTs using tickets
|
|
for (auto const nftokenID : nftIDs)
|
|
{
|
|
env(token::burn(minter, nftokenID), ticket::use(minterTicketSeq++));
|
|
}
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ticketCount(env, minter) == 0);
|
|
|
|
// Increment ledger sequence to the number that is
|
|
// enforced by the featureDeletableAccounts amendment
|
|
incLgrSeqForAcctDel(env, alice);
|
|
|
|
// Verify that alice's account root is present.
|
|
Keylet const aliceAcctKey{keylet::account(alice.id())};
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
// alice tries to delete her account, but is unsuccessful.
|
|
// Due to authorized minting, alice's account sequence does not
|
|
// advance while minter mints NFTokens for her using tickets.
|
|
// The new account deletion retriction <FirstNFTokenSequence +
|
|
// MintedNFTokens + 256> enabled by this amendment will enforce
|
|
// alice to wait for more ledgers to close before she can delete her
|
|
// account, to prevent duplicate NFTokenIDs
|
|
auto const acctDelFee{drops(env.current()->fees().increment)};
|
|
env(acctdelete(alice, becky), fee(acctDelFee), ter(tecTOO_SOON));
|
|
env.close();
|
|
|
|
// alice's account is still present
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
|
|
// Close more ledgers until it is no longer within
|
|
// <FirstNFTokenSequence + MintedNFTokens + 256>
|
|
// to be able to delete alice's account
|
|
incLgrSeqForFixNftRemint(env, alice);
|
|
|
|
// alice's account is deleted
|
|
env(acctdelete(alice, becky), fee(acctDelFee));
|
|
env.close();
|
|
|
|
// alice's account root is gone from the most recently
|
|
// closed ledger and the current ledger.
|
|
BEAST_EXPECT(!env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(!env.current()->exists(aliceAcctKey));
|
|
|
|
// Fund alice to re-create her account
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// alice's account now exists and has minted 0 NFTokens
|
|
BEAST_EXPECT(env.closed()->exists(aliceAcctKey));
|
|
BEAST_EXPECT(env.current()->exists(aliceAcctKey));
|
|
BEAST_EXPECT((*env.le(alice))[sfMintedNFTokens] == 0);
|
|
|
|
// The new NFT minted will not have the same ID
|
|
// as any of the NFTs authorized minter minted using tickets
|
|
uint256 const remintNFTokenID = token::getNextID(env, alice, 0u);
|
|
env(token::mint(alice));
|
|
env.close();
|
|
|
|
// burn the NFT to make sure alice owns remintNFTokenID
|
|
env(token::burn(alice, remintNFTokenID));
|
|
env.close();
|
|
|
|
// The new NFT minted will not have the same ID
|
|
// as one of NFTs authorized minter minted using tickets
|
|
BEAST_EXPECT(
|
|
std::find(nftIDs.begin(), nftIDs.end(), remintNFTokenID) ==
|
|
nftIDs.end());
|
|
}
|
|
|
|
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};
|
|
auto const baseFee = env.current()->fees().base;
|
|
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().reserve;
|
|
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(baseFee)));
|
|
env.close();
|
|
env(token::mint(bob), token::amount(XRP(0)));
|
|
env.close();
|
|
|
|
// doesn't have reserve for Offer
|
|
env(pay(env.master, bob, drops(baseFee)));
|
|
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(baseFee)));
|
|
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);
|
|
}
|
|
|
|
Env env{*this, features};
|
|
Account const alice("alice");
|
|
|
|
env.fund(XRP(1000000), alice);
|
|
|
|
TER const offerCreateTER = temBAD_AMOUNT;
|
|
|
|
// Make offers with negative amounts for the NFTs
|
|
env(token::mint(alice), token::amount(XRP(-2)), ter(offerCreateTER));
|
|
env.close();
|
|
}
|
|
|
|
void
|
|
testTxJsonMetaFields(FeatureBitset features)
|
|
{
|
|
// `nftoken_id` is added in the `tx` response for NFTokenMint and
|
|
// NFTokenAcceptOffer.
|
|
//
|
|
// `nftoken_ids` is added in the `tx` response for NFTokenCancelOffer
|
|
//
|
|
// `offer_id` is added in the `tx` response for NFTokenCreateOffer
|
|
//
|
|
// The values of these fields are dependent on the NFTokenID/OfferID
|
|
// changed in its corresponding transaction. We want to validate each
|
|
// transaction to make sure the synethic fields hold the right values.
|
|
|
|
testcase("Test synthetic fields from JSON response");
|
|
|
|
using namespace test::jtx;
|
|
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const broker{"broker"};
|
|
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), alice, bob, broker);
|
|
env.close();
|
|
|
|
// Verify `nftoken_id` value equals to the NFTokenID that was
|
|
// changed in the most recent NFTokenMint or NFTokenAcceptOffer
|
|
// transaction
|
|
auto verifyNFTokenID = [&](uint256 const& actualNftID) {
|
|
// Get the hash for the most recent transaction.
|
|
std::string const txHash{
|
|
env.tx()->getJson(JsonOptions::none)[jss::hash].asString()};
|
|
|
|
env.close();
|
|
Json::Value const meta =
|
|
env.rpc("tx", txHash)[jss::result][jss::meta];
|
|
|
|
// Expect nftokens_id field
|
|
if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id)))
|
|
return;
|
|
|
|
// Check the value of NFT ID in the meta with the
|
|
// actual value
|
|
uint256 nftID;
|
|
BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString()));
|
|
BEAST_EXPECT(nftID == actualNftID);
|
|
};
|
|
|
|
// Verify `nftoken_ids` value equals to the NFTokenIDs that were
|
|
// changed in the most recent NFTokenCancelOffer transaction
|
|
auto verifyNFTokenIDsInCancelOffer =
|
|
[&](std::vector<uint256> actualNftIDs) {
|
|
// Get the hash for the most recent transaction.
|
|
std::string const txHash{
|
|
env.tx()->getJson(JsonOptions::none)[jss::hash].asString()};
|
|
|
|
env.close();
|
|
Json::Value const meta =
|
|
env.rpc("tx", txHash)[jss::result][jss::meta];
|
|
|
|
// Expect nftokens_ids field and verify the values
|
|
if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids)))
|
|
return;
|
|
|
|
// Convert NFT IDs from Json::Value to uint256
|
|
std::vector<uint256> metaIDs;
|
|
std::transform(
|
|
meta[jss::nftoken_ids].begin(),
|
|
meta[jss::nftoken_ids].end(),
|
|
std::back_inserter(metaIDs),
|
|
[this](Json::Value id) {
|
|
uint256 nftID;
|
|
BEAST_EXPECT(nftID.parseHex(id.asString()));
|
|
return nftID;
|
|
});
|
|
|
|
// Sort both array to prepare for comparison
|
|
std::sort(metaIDs.begin(), metaIDs.end());
|
|
std::sort(actualNftIDs.begin(), actualNftIDs.end());
|
|
|
|
// Make sure the expect number of NFTs is correct
|
|
BEAST_EXPECT(metaIDs.size() == actualNftIDs.size());
|
|
|
|
// Check the value of NFT ID in the meta with the
|
|
// actual values
|
|
for (size_t i = 0; i < metaIDs.size(); ++i)
|
|
BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]);
|
|
};
|
|
|
|
// Verify `offer_id` value equals to the offerID that was
|
|
// changed in the most recent NFTokenCreateOffer tx
|
|
auto verifyNFTokenOfferID = [&](uint256 const& offerID) {
|
|
// Get the hash for the most recent transaction.
|
|
std::string const txHash{
|
|
env.tx()->getJson(JsonOptions::none)[jss::hash].asString()};
|
|
|
|
env.close();
|
|
Json::Value const meta =
|
|
env.rpc("tx", txHash)[jss::result][jss::meta];
|
|
|
|
// Expect offer_id field and verify the value
|
|
if (!BEAST_EXPECT(meta.isMember(jss::offer_id)))
|
|
return;
|
|
|
|
uint256 metaOfferID;
|
|
BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString()));
|
|
BEAST_EXPECT(metaOfferID == offerID);
|
|
};
|
|
|
|
// Check new fields in tx meta when for all NFTtransactions
|
|
{
|
|
// Alice mints 2 NFTs
|
|
// Verify the NFTokenIDs are correct in the NFTokenMint tx meta
|
|
uint256 const nftId1{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
verifyNFTokenID(nftId1);
|
|
|
|
uint256 const nftId2{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
verifyNFTokenID(nftId2);
|
|
|
|
// Alice creates one sell offer for each NFT
|
|
// Verify the offer indexes are correct in the NFTokenCreateOffer tx
|
|
// meta
|
|
uint256 const aliceOfferIndex1 =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId1, drops(1)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
verifyNFTokenOfferID(aliceOfferIndex1);
|
|
|
|
uint256 const aliceOfferIndex2 =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId2, drops(1)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
verifyNFTokenOfferID(aliceOfferIndex2);
|
|
|
|
// Alice cancels two offers she created
|
|
// Verify the NFTokenIDs are correct in the NFTokenCancelOffer tx
|
|
// meta
|
|
env(token::cancelOffer(
|
|
alice, {aliceOfferIndex1, aliceOfferIndex2}));
|
|
env.close();
|
|
verifyNFTokenIDsInCancelOffer({nftId1, nftId2});
|
|
|
|
// Bobs creates a buy offer for nftId1
|
|
// Verify the offer id is correct in the NFTokenCreateOffer tx meta
|
|
auto const bobBuyOfferIndex =
|
|
keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId1, drops(1)), token::owner(alice));
|
|
env.close();
|
|
verifyNFTokenOfferID(bobBuyOfferIndex);
|
|
|
|
// Alice accepts bob's buy offer
|
|
// Verify the NFTokenID is correct in the NFTokenAcceptOffer tx meta
|
|
env(token::acceptBuyOffer(alice, bobBuyOfferIndex));
|
|
env.close();
|
|
verifyNFTokenID(nftId1);
|
|
}
|
|
|
|
// Check `nftoken_ids` in brokered mode
|
|
{
|
|
// Alice mints a NFT
|
|
uint256 const nftId{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
verifyNFTokenID(nftId);
|
|
|
|
// Alice creates sell offer and set broker as destination
|
|
uint256 const offerAliceToBroker =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId, drops(1)),
|
|
token::destination(broker),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
verifyNFTokenOfferID(offerAliceToBroker);
|
|
|
|
// Bob creates buy offer
|
|
uint256 const offerBobToBroker =
|
|
keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId, drops(1)), token::owner(alice));
|
|
env.close();
|
|
verifyNFTokenOfferID(offerBobToBroker);
|
|
|
|
// Check NFTokenID meta for NFTokenAcceptOffer in brokered mode
|
|
env(token::brokerOffers(
|
|
broker, offerBobToBroker, offerAliceToBroker));
|
|
env.close();
|
|
verifyNFTokenID(nftId);
|
|
}
|
|
|
|
// Check if there are no duplicate nft id in Cancel transactions where
|
|
// multiple offers are cancelled for the same NFT
|
|
{
|
|
// Alice mints a NFT
|
|
uint256 const nftId{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
verifyNFTokenID(nftId);
|
|
|
|
// Alice creates 2 sell offers for the same NFT
|
|
uint256 const aliceOfferIndex1 =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId, drops(1)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
verifyNFTokenOfferID(aliceOfferIndex1);
|
|
|
|
uint256 const aliceOfferIndex2 =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId, drops(1)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
verifyNFTokenOfferID(aliceOfferIndex2);
|
|
|
|
// Make sure the metadata only has 1 nft id, since both offers are
|
|
// for the same nft
|
|
env(token::cancelOffer(
|
|
alice, {aliceOfferIndex1, aliceOfferIndex2}));
|
|
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
|
|
testFixNFTokenBuyerReserve(FeatureBitset features)
|
|
{
|
|
testcase("Test buyer reserve when accepting an offer");
|
|
|
|
using namespace test::jtx;
|
|
|
|
// Lambda that mints an NFT and then creates a sell offer
|
|
auto mintAndCreateSellOffer = [](test::jtx::Env& env,
|
|
test::jtx::Account const& acct,
|
|
STAmount const amt) -> uint256 {
|
|
// acct mints a NFT
|
|
uint256 const nftId{
|
|
token::getNextID(env, acct, 0u, tfTransferable)};
|
|
env(token::mint(acct, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// acct makes an sell offer
|
|
uint256 const sellOfferIndex =
|
|
keylet::nftoffer(acct, env.seq(acct)).key;
|
|
env(token::createOffer(acct, nftId, amt), txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
return sellOfferIndex;
|
|
};
|
|
|
|
// Test the behaviors when the buyer makes an accept offer, both before
|
|
// and after enabling the amendment. Exercises the precise number of
|
|
// reserve in drops that's required to accept the offer
|
|
{
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
|
|
Env env{*this, features};
|
|
auto const acctReserve = env.current()->fees().reserve;
|
|
auto const incReserve = env.current()->fees().increment;
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// Bob is funded with minimum XRP reserve
|
|
env.fund(acctReserve, bob);
|
|
env.close();
|
|
|
|
// alice mints an NFT and create a sell offer for 0 XRP
|
|
auto const sellOfferIndex =
|
|
mintAndCreateSellOffer(env, alice, XRP(0));
|
|
|
|
// Bob owns no object
|
|
BEAST_EXPECT(ownerCount(env, bob) == 0);
|
|
|
|
// Without fixNFTokenReserve amendment, when bob accepts an NFT sell
|
|
// offer, he can get the NFT free of reserve
|
|
if (!features[fixNFTokenReserve])
|
|
{
|
|
// Bob is able to accept the offer
|
|
env(token::acceptSellOffer(bob, sellOfferIndex));
|
|
env.close();
|
|
|
|
// Bob now owns an extra objects
|
|
BEAST_EXPECT(ownerCount(env, bob) == 1);
|
|
|
|
// This is the wrong behavior, since Bob should need at least
|
|
// one incremental reserve.
|
|
}
|
|
// With fixNFTokenReserve, bob can no longer accept the offer unless
|
|
// there is enough reserve. A detail to note is that NFTs(sell
|
|
// offer) will not allow one to go below the reserve requirement,
|
|
// because buyer's balance is computed after the transaction fee is
|
|
// deducted. This means that the reserve requirement will be `base
|
|
// fee` drops higher than normal.
|
|
else
|
|
{
|
|
// Bob is not able to accept the offer with only the account
|
|
// reserve (200,000,000 drops)
|
|
env(token::acceptSellOffer(bob, sellOfferIndex),
|
|
ter(tecINSUFFICIENT_RESERVE));
|
|
env.close();
|
|
|
|
// after prev transaction, Bob owns `200M - base fee` drops due
|
|
// to burnt tx fee
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 0);
|
|
|
|
// Send bob an increment reserve and base fee (to make up for
|
|
// the transaction fee burnt from the prev failed tx) Bob now
|
|
// owns 250,000,000 drops
|
|
env(pay(env.master, bob, incReserve + drops(baseFee)));
|
|
env.close();
|
|
|
|
// However, this transaction will still fail because the reserve
|
|
// requirement is `base fee` drops higher
|
|
env(token::acceptSellOffer(bob, sellOfferIndex),
|
|
ter(tecINSUFFICIENT_RESERVE));
|
|
env.close();
|
|
|
|
// Send bob `base fee * 2` drops
|
|
// Bob now owns `250M + base fee` drops
|
|
env(pay(env.master, bob, drops(baseFee * 2)));
|
|
env.close();
|
|
|
|
// Bob is now able to accept the offer
|
|
env(token::acceptSellOffer(bob, sellOfferIndex));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 1);
|
|
}
|
|
}
|
|
|
|
// Now exercise the scenario when the buyer accepts
|
|
// many sell offers
|
|
{
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
|
|
Env env{*this, features};
|
|
auto const acctReserve = env.current()->fees().reserve;
|
|
auto const incReserve = env.current()->fees().increment;
|
|
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
env.fund(acctReserve + XRP(1), bob);
|
|
env.close();
|
|
|
|
if (!features[fixNFTokenReserve])
|
|
{
|
|
// Bob can accept many NFTs without having a single reserve!
|
|
for (size_t i = 0; i < 200; i++)
|
|
{
|
|
// alice mints an NFT and creates a sell offer for 0 XRP
|
|
auto const sellOfferIndex =
|
|
mintAndCreateSellOffer(env, alice, XRP(0));
|
|
|
|
// Bob is able to accept the offer
|
|
env(token::acceptSellOffer(bob, sellOfferIndex));
|
|
env.close();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// alice mints the first NFT and creates a sell offer for 0 XRP
|
|
auto const sellOfferIndex1 =
|
|
mintAndCreateSellOffer(env, alice, XRP(0));
|
|
|
|
// Bob cannot accept this offer because he doesn't have the
|
|
// reserve for the NFT
|
|
env(token::acceptSellOffer(bob, sellOfferIndex1),
|
|
ter(tecINSUFFICIENT_RESERVE));
|
|
env.close();
|
|
|
|
// Give bob enough reserve
|
|
env(pay(env.master, bob, drops(incReserve)));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 0);
|
|
|
|
// Bob now owns his first NFT
|
|
env(token::acceptSellOffer(bob, sellOfferIndex1));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 1);
|
|
|
|
// alice now mints 31 more NFTs and creates an offer for each
|
|
// NFT, then sells to bob
|
|
for (size_t i = 0; i < 31; i++)
|
|
{
|
|
// alice mints an NFT and creates a sell offer for 0 XRP
|
|
auto const sellOfferIndex =
|
|
mintAndCreateSellOffer(env, alice, XRP(0));
|
|
|
|
// Bob can accept the offer because the new NFT is stored in
|
|
// an existing NFTokenPage so no new reserve is required
|
|
env(token::acceptSellOffer(bob, sellOfferIndex));
|
|
env.close();
|
|
}
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 1);
|
|
|
|
// alice now mints the 33rd NFT and creates an sell offer for 0
|
|
// XRP
|
|
auto const sellOfferIndex33 =
|
|
mintAndCreateSellOffer(env, alice, XRP(0));
|
|
|
|
// Bob fails to accept this NFT because he does not have enough
|
|
// reserve for a new NFTokenPage
|
|
env(token::acceptSellOffer(bob, sellOfferIndex33),
|
|
ter(tecINSUFFICIENT_RESERVE));
|
|
env.close();
|
|
|
|
// Send bob incremental reserve
|
|
env(pay(env.master, bob, drops(incReserve)));
|
|
env.close();
|
|
|
|
// Bob now has enough reserve to accept the offer and now
|
|
// owns one more NFTokenPage
|
|
env(token::acceptSellOffer(bob, sellOfferIndex33));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(ownerCount(env, bob) == 2);
|
|
}
|
|
}
|
|
|
|
// Test the behavior when the seller accepts a buy offer.
|
|
// The behavior should not change regardless whether fixNFTokenReserve
|
|
// is enabled or not, since the ledger is able to guard against
|
|
// free NFTokenPages when buy offer is accepted. This is merely an
|
|
// additional test to exercise existing offer behavior.
|
|
{
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
|
|
Env env{*this, features};
|
|
auto const acctReserve = env.current()->fees().reserve;
|
|
auto const incReserve = env.current()->fees().increment;
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// Bob is funded with account reserve + increment reserve + 1 XRP
|
|
// increment reserve is for the buy offer, and 1 XRP is for offer
|
|
// price
|
|
env.fund(acctReserve + incReserve + XRP(1), bob);
|
|
env.close();
|
|
|
|
// Alice mints a NFT
|
|
uint256 const nftId{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// Bob makes a buy offer for 1 XRP
|
|
auto const buyOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId, XRP(1)), token::owner(alice));
|
|
env.close();
|
|
|
|
// accepting the buy offer fails because bob's balance is `base fee`
|
|
// drops lower than the required amount, since the previous tx burnt
|
|
// drops for tx fee.
|
|
env(token::acceptBuyOffer(alice, buyOfferIndex),
|
|
ter(tecINSUFFICIENT_FUNDS));
|
|
env.close();
|
|
|
|
// send Bob `base fee` drops
|
|
env(pay(env.master, bob, drops(baseFee)));
|
|
env.close();
|
|
|
|
// Now bob can buy the offer
|
|
env(token::acceptBuyOffer(alice, buyOfferIndex));
|
|
env.close();
|
|
}
|
|
|
|
// Test the reserve behavior in brokered mode.
|
|
// The behavior should not change regardless whether fixNFTokenReserve
|
|
// is enabled or not, since the ledger is able to guard against
|
|
// free NFTokenPages in brokered mode. This is merely an
|
|
// additional test to exercise existing offer behavior.
|
|
{
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const broker{"broker"};
|
|
|
|
Env env{*this, features};
|
|
auto const acctReserve = env.current()->fees().reserve;
|
|
auto const incReserve = env.current()->fees().increment;
|
|
auto const baseFee = env.current()->fees().base;
|
|
|
|
env.fund(XRP(10000), alice, broker);
|
|
env.close();
|
|
|
|
// Bob is funded with account reserve + incr reserve + 1 XRP(offer
|
|
// price)
|
|
env.fund(acctReserve + incReserve + XRP(1), bob);
|
|
env.close();
|
|
|
|
// Alice mints a NFT
|
|
uint256 const nftId{
|
|
token::getNextID(env, alice, 0u, tfTransferable)};
|
|
env(token::mint(alice, 0u), txflags(tfTransferable));
|
|
env.close();
|
|
|
|
// Alice creates sell offer and set broker as destination
|
|
uint256 const offerAliceToBroker =
|
|
keylet::nftoffer(alice, env.seq(alice)).key;
|
|
env(token::createOffer(alice, nftId, XRP(1)),
|
|
token::destination(broker),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
// Bob creates buy offer
|
|
uint256 const offerBobToBroker =
|
|
keylet::nftoffer(bob, env.seq(bob)).key;
|
|
env(token::createOffer(bob, nftId, XRP(1)), token::owner(alice));
|
|
env.close();
|
|
|
|
// broker offers.
|
|
// Returns insufficient funds, because bob burnt tx fee when he
|
|
// created his buy offer, which makes his spendable balance to be
|
|
// less than the required amount.
|
|
env(token::brokerOffers(
|
|
broker, offerBobToBroker, offerAliceToBroker),
|
|
ter(tecINSUFFICIENT_FUNDS));
|
|
env.close();
|
|
|
|
// send Bob `base fee` drops
|
|
env(pay(env.master, bob, drops(baseFee)));
|
|
env.close();
|
|
|
|
// broker offers.
|
|
env(token::brokerOffers(
|
|
broker, offerBobToBroker, offerAliceToBroker));
|
|
env.close();
|
|
}
|
|
}
|
|
|
|
void
|
|
testUnaskedForAutoTrustline(FeatureBitset features)
|
|
{
|
|
testcase("Test fix unasked for auto-trustline.");
|
|
|
|
using namespace test::jtx;
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const becky{"becky"};
|
|
Account const cheri{"cheri"};
|
|
Account const gw("gw");
|
|
IOU const gwAUD(gw["AUD"]);
|
|
|
|
// This test case covers issue...
|
|
// https://github.com/XRPLF/rippled/issues/4925
|
|
//
|
|
// For an NFToken with a transfer fee, the issuer must be able to
|
|
// accept the transfer fee or else a transfer should fail. If the
|
|
// NFToken is transferred for a non-XRP asset, then the issuer must
|
|
// have a trustline to that asset to receive the fee.
|
|
//
|
|
// This test looks at a situation where issuer would get a trustline
|
|
// for the fee without the issuer's consent. Here are the steps:
|
|
// 1. Issuer has a trustline (i.e., USD)
|
|
// 2. Issuer mints NFToken with transfer fee.
|
|
// 3. Becky acquires the NFToken, paying with XRP.
|
|
// 4. Becky creates offer to sell NFToken for USD(100).
|
|
// 5. Issuer deletes trustline for USD.
|
|
// 6. Carol buys NFToken from Becky for USD(100).
|
|
// 7. The transfer fee from Carol's purchase re-establishes issuer's
|
|
// USD trustline.
|
|
//
|
|
// The fixEnforceNFTokenTrustline amendment addresses this oversight.
|
|
//
|
|
// We run this test case both with and without
|
|
// fixEnforceNFTokenTrustline enabled so we can see the change
|
|
// in behavior.
|
|
//
|
|
// In both cases we remove the fixRemoveNFTokenAutoTrustLine amendment.
|
|
// Otherwise we can't create NFTokens with tfTrustLine enabled.
|
|
FeatureBitset const localFeatures =
|
|
features - fixRemoveNFTokenAutoTrustLine;
|
|
for (FeatureBitset feats :
|
|
{localFeatures - fixEnforceNFTokenTrustline,
|
|
localFeatures | fixEnforceNFTokenTrustline})
|
|
{
|
|
Env env{*this, feats};
|
|
env.fund(XRP(1000), issuer, 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.close();
|
|
env(pay(gw, cheri, gwAUD(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();
|
|
}
|
|
|
|
// becky creates offers to sell the nfts for AUD.
|
|
uint256 const beckyAutoTrustOfferIndex =
|
|
keylet::nftoffer(becky, env.seq(becky)).key;
|
|
env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
// Creating an offer for the NFToken without tfTrustLine fails
|
|
// because issuer does not have a trust line for AUD.
|
|
env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)),
|
|
txflags(tfSellNFToken),
|
|
ter(tecNO_LINE));
|
|
env.close();
|
|
|
|
// issuer creates a trust line. Now the offer create for the
|
|
// NFToken without tfTrustLine succeeds.
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
env(trust(issuer, gwAUD(1000)));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
|
|
uint256 const beckyNoAutoTrustOfferIndex =
|
|
keylet::nftoffer(becky, env.seq(becky)).key;
|
|
env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
|
|
// Now that the offers are in place, issuer removes the trustline.
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
env(trust(issuer, gwAUD(0)));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
|
|
// cheri attempts to accept becky's offers. Behavior with the
|
|
// AutoTrustline NFT is uniform: issuer gets a new trust line.
|
|
env(token::acceptSellOffer(cheri, beckyAutoTrustOfferIndex));
|
|
env.close();
|
|
|
|
// Here's evidence that issuer got the new AUD trust line.
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
BEAST_EXPECT(env.balance(issuer, gwAUD) == gwAUD(5));
|
|
|
|
// issuer once again removes the trust line for AUD.
|
|
env(pay(issuer, gw, gwAUD(5)));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
|
|
// cheri attempts to accept the NoAutoTrustLine NFT. Behavior
|
|
// depends on whether fixEnforceNFTokenTrustline is enabled.
|
|
if (feats[fixEnforceNFTokenTrustline])
|
|
{
|
|
// With fixEnforceNFTokenTrustline cheri can't accept the
|
|
// offer because issuer could not get their transfer fee
|
|
// without the appropriate trustline.
|
|
env(token::acceptSellOffer(cheri, beckyNoAutoTrustOfferIndex),
|
|
ter(tecNO_LINE));
|
|
env.close();
|
|
|
|
// But if issuer re-establishes the trustline then the offer
|
|
// can be accepted.
|
|
env(trust(issuer, gwAUD(1000)));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
|
|
env(token::acceptSellOffer(cheri, beckyNoAutoTrustOfferIndex));
|
|
env.close();
|
|
}
|
|
else
|
|
{
|
|
// Without fixEnforceNFTokenTrustline the offer just works
|
|
// and issuer gets a trustline that they did not request.
|
|
env(token::acceptSellOffer(cheri, beckyNoAutoTrustOfferIndex));
|
|
env.close();
|
|
}
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
BEAST_EXPECT(env.balance(issuer, gwAUD) == gwAUD(5));
|
|
} // for feats
|
|
}
|
|
|
|
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
|
|
testNFTokenModify(FeatureBitset features)
|
|
{
|
|
testcase("Test NFTokenModify");
|
|
|
|
using namespace test::jtx;
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const alice("alice");
|
|
Account const bob("bob");
|
|
|
|
bool const modifyEnabled = features[featureDynamicNFT];
|
|
|
|
{
|
|
// Mint with tfMutable
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer);
|
|
env.close();
|
|
|
|
auto const expectedTer =
|
|
modifyEnabled ? TER{tesSUCCESS} : TER{temINVALID_FLAG};
|
|
env(token::mint(issuer, 0u), txflags(tfMutable), ter(expectedTer));
|
|
env.close();
|
|
}
|
|
{
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer);
|
|
env.close();
|
|
|
|
// Modify a nftoken
|
|
uint256 const nftId{token::getNextID(env, issuer, 0u, tfMutable)};
|
|
if (modifyEnabled)
|
|
{
|
|
env(token::mint(issuer, 0u), txflags(tfMutable));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
env(token::modify(issuer, nftId));
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 1);
|
|
}
|
|
else
|
|
{
|
|
env(token::mint(issuer, 0u));
|
|
env.close();
|
|
env(token::modify(issuer, nftId), ter(temDISABLED));
|
|
env.close();
|
|
}
|
|
}
|
|
if (!modifyEnabled)
|
|
return;
|
|
|
|
{
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer);
|
|
env.close();
|
|
|
|
uint256 const nftId{token::getNextID(env, issuer, 0u, tfMutable)};
|
|
env(token::mint(issuer, 0u), txflags(tfMutable));
|
|
env.close();
|
|
|
|
// Set a negative fee. Exercises invalid preflight1.
|
|
env(token::modify(issuer, nftId),
|
|
fee(STAmount(10ull, true)),
|
|
ter(temBAD_FEE));
|
|
env.close();
|
|
|
|
// Invalid Flags
|
|
env(token::modify(issuer, nftId),
|
|
txflags(0x00000001),
|
|
ter(temINVALID_FLAG));
|
|
|
|
// Invalid Owner
|
|
env(token::modify(issuer, nftId),
|
|
token::owner(issuer),
|
|
ter(temMALFORMED));
|
|
env.close();
|
|
|
|
// Invalid URI length = 0
|
|
env(token::modify(issuer, nftId),
|
|
token::uri(""),
|
|
ter(temMALFORMED));
|
|
env.close();
|
|
|
|
// Invalid URI length > 256
|
|
env(token::modify(issuer, nftId),
|
|
token::uri(std::string(maxTokenURILength + 1, 'q')),
|
|
ter(temMALFORMED));
|
|
env.close();
|
|
}
|
|
{
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer, alice, bob);
|
|
env.close();
|
|
|
|
{
|
|
// NFToken not exists
|
|
uint256 const nftIDNotExists{
|
|
token::getNextID(env, issuer, 0u, tfMutable)};
|
|
env.close();
|
|
|
|
env(token::modify(issuer, nftIDNotExists), ter(tecNO_ENTRY));
|
|
env.close();
|
|
}
|
|
{
|
|
// Invalid NFToken flag
|
|
uint256 const nftIDNotModifiable{
|
|
token::getNextID(env, issuer, 0u)};
|
|
env(token::mint(issuer, 0u));
|
|
env.close();
|
|
|
|
env(token::modify(issuer, nftIDNotModifiable),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
}
|
|
{
|
|
// Unauthorized account
|
|
uint256 const nftId{
|
|
token::getNextID(env, issuer, 0u, tfMutable)};
|
|
env(token::mint(issuer, 0u), txflags(tfMutable));
|
|
env.close();
|
|
|
|
env(token::modify(bob, nftId),
|
|
token::owner(issuer),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
|
|
env(token::setMinter(issuer, alice));
|
|
env.close();
|
|
|
|
env(token::modify(bob, nftId),
|
|
token::owner(issuer),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
}
|
|
}
|
|
{
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer, alice, bob);
|
|
env.close();
|
|
|
|
// modify with tfFullyCanonicalSig should success
|
|
uint256 const nftId{token::getNextID(env, issuer, 0u, tfMutable)};
|
|
env(token::mint(issuer, 0u), txflags(tfMutable), token::uri("uri"));
|
|
env.close();
|
|
|
|
env(token::modify(issuer, nftId), txflags(tfFullyCanonicalSig));
|
|
env.close();
|
|
}
|
|
{
|
|
Env env{*this, features};
|
|
env.fund(XRP(10000), issuer, alice, bob);
|
|
env.close();
|
|
|
|
// lambda that returns the JSON form of NFTokens held by acct
|
|
auto accountNFTs = [&env](Account const& acct) {
|
|
Json::Value params;
|
|
params[jss::account] = acct.human();
|
|
params[jss::type] = "state";
|
|
auto response =
|
|
env.rpc("json", "account_nfts", to_string(params));
|
|
return response[jss::result][jss::account_nfts];
|
|
};
|
|
|
|
// lambda that checks for the expected URI value of an NFToken
|
|
auto checkURI = [&accountNFTs, this](
|
|
Account const& acct,
|
|
char const* uri,
|
|
int line) {
|
|
auto const nfts = accountNFTs(acct);
|
|
if (nfts.size() == 1)
|
|
pass();
|
|
else
|
|
{
|
|
std::ostringstream text;
|
|
text << "checkURI: unexpected NFT count on line " << line;
|
|
fail(text.str(), __FILE__, line);
|
|
return;
|
|
}
|
|
|
|
if (uri == nullptr)
|
|
{
|
|
if (!nfts[0u].isMember(sfURI.jsonName))
|
|
pass();
|
|
else
|
|
{
|
|
std::ostringstream text;
|
|
text << "checkURI: unexpected URI present on line "
|
|
<< line;
|
|
fail(text.str(), __FILE__, line);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (nfts[0u][sfURI.jsonName] == strHex(std::string(uri)))
|
|
pass();
|
|
else
|
|
{
|
|
std::ostringstream text;
|
|
text << "checkURI: unexpected URI contents on line "
|
|
<< line;
|
|
fail(text.str(), __FILE__, line);
|
|
}
|
|
};
|
|
|
|
uint256 const nftId{token::getNextID(env, issuer, 0u, tfMutable)};
|
|
env.close();
|
|
|
|
env(token::mint(issuer, 0u), txflags(tfMutable), token::uri("uri"));
|
|
env.close();
|
|
checkURI(issuer, "uri", __LINE__);
|
|
|
|
// set URI Field
|
|
env(token::modify(issuer, nftId), token::uri("new_uri"));
|
|
env.close();
|
|
checkURI(issuer, "new_uri", __LINE__);
|
|
|
|
// unset URI Field
|
|
env(token::modify(issuer, nftId));
|
|
env.close();
|
|
checkURI(issuer, nullptr, __LINE__);
|
|
|
|
// set URI Field
|
|
env(token::modify(issuer, nftId), token::uri("uri"));
|
|
env.close();
|
|
checkURI(issuer, "uri", __LINE__);
|
|
|
|
// Account != Owner
|
|
uint256 const offerID =
|
|
keylet::nftoffer(issuer, env.seq(issuer)).key;
|
|
env(token::createOffer(issuer, nftId, XRP(0)),
|
|
txflags(tfSellNFToken));
|
|
env.close();
|
|
env(token::acceptSellOffer(alice, offerID));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, alice) == 1);
|
|
checkURI(alice, "uri", __LINE__);
|
|
|
|
// Modify by owner fails.
|
|
env(token::modify(alice, nftId),
|
|
token::uri("new_uri"),
|
|
ter(tecNO_PERMISSION));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, alice) == 1);
|
|
checkURI(alice, "uri", __LINE__);
|
|
|
|
env(token::modify(issuer, nftId),
|
|
token::owner(alice),
|
|
token::uri("new_uri"));
|
|
env.close();
|
|
BEAST_EXPECT(ownerCount(env, issuer) == 0);
|
|
BEAST_EXPECT(ownerCount(env, alice) == 1);
|
|
checkURI(alice, "new_uri", __LINE__);
|
|
|
|
env(token::modify(issuer, nftId), token::owner(alice));
|
|
env.close();
|
|
checkURI(alice, nullptr, __LINE__);
|
|
|
|
env(token::modify(issuer, nftId),
|
|
token::owner(alice),
|
|
token::uri("uri"));
|
|
env.close();
|
|
checkURI(alice, "uri", __LINE__);
|
|
|
|
// Modify by authorized minter
|
|
env(token::setMinter(issuer, bob));
|
|
env.close();
|
|
env(token::modify(bob, nftId),
|
|
token::owner(alice),
|
|
token::uri("new_uri"));
|
|
env.close();
|
|
checkURI(alice, "new_uri", __LINE__);
|
|
|
|
env(token::modify(bob, nftId), token::owner(alice));
|
|
env.close();
|
|
checkURI(alice, nullptr, __LINE__);
|
|
|
|
env(token::modify(bob, nftId),
|
|
token::owner(alice),
|
|
token::uri("uri"));
|
|
env.close();
|
|
checkURI(alice, "uri", __LINE__);
|
|
}
|
|
}
|
|
|
|
protected:
|
|
FeatureBitset const allFeatures{test::jtx::testable_amendments()};
|
|
|
|
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);
|
|
testCreateOfferDestinationDisallowIncoming(features);
|
|
testCreateOfferExpiration(features);
|
|
testCancelOffers(features);
|
|
testCancelTooManyOffers(features);
|
|
testBrokeredAccept(features);
|
|
testNFTokenOfferOwner(features);
|
|
testNFTokenWithTickets(features);
|
|
testNFTokenDeleteAccount(features);
|
|
testNftXxxOffers(features);
|
|
testNFTokenNegOffer(features);
|
|
testIOUWithTransferFee(features);
|
|
testBrokeredSaleToSelf(features);
|
|
testNFTokenRemint(features);
|
|
testFeatMintWithOffer(features);
|
|
testTxJsonMetaFields(features);
|
|
testFixNFTokenBuyerReserve(features);
|
|
testUnaskedForAutoTrustline(features);
|
|
testNFTIssuerIsIOUIssuer(features);
|
|
testNFTokenModify(features);
|
|
}
|
|
|
|
public:
|
|
void
|
|
run() override
|
|
{
|
|
testWithFeats(
|
|
allFeatures - fixNFTokenReserve - featureNFTokenMintOffer -
|
|
featureDynamicNFT);
|
|
}
|
|
};
|
|
|
|
class NFTokenDisallowIncoming_test : public NFTokenBaseUtil_test
|
|
{
|
|
void
|
|
run() override
|
|
{
|
|
testWithFeats(
|
|
allFeatures - featureDisallowIncoming - fixNFTokenReserve -
|
|
featureNFTokenMintOffer - featureDynamicNFT);
|
|
}
|
|
};
|
|
|
|
class NFTokenWOMintOffer_test : public NFTokenBaseUtil_test
|
|
{
|
|
void
|
|
run() override
|
|
{
|
|
testWithFeats(
|
|
allFeatures - featureNFTokenMintOffer - featureDynamicNFT);
|
|
}
|
|
};
|
|
|
|
class NFTokenWOModify_test : public NFTokenBaseUtil_test
|
|
{
|
|
void
|
|
run() override
|
|
{
|
|
testWithFeats(allFeatures - featureDynamicNFT);
|
|
}
|
|
};
|
|
|
|
class NFTokenAllFeatures_test : public NFTokenBaseUtil_test
|
|
{
|
|
void
|
|
run() override
|
|
{
|
|
testWithFeats(allFeatures);
|
|
}
|
|
};
|
|
|
|
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenBaseUtil, app, ripple, 2);
|
|
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenDisallowIncoming, app, ripple, 2);
|
|
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOMintOffer, app, ripple, 2);
|
|
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenWOModify, app, ripple, 2);
|
|
BEAST_DEFINE_TESTSUITE_PRIO(NFTokenAllFeatures, app, ripple, 2);
|
|
|
|
} // namespace ripple
|