From 6bf6f43fba7751a03436a1f36183b57a8ee7af46 Mon Sep 17 00:00:00 2001 From: ledhed2222 Date: Thu, 9 Feb 2023 16:57:51 -0500 Subject: [PATCH] Fix 3 issues around NFToken offer acceptance (#4380) Fixes 3 issues: In the following scenario, an account cannot perform NFTokenAcceptOffer even though it should be allowed to: - BROKER has < S - ALICE offers to sell token for S - BOB offers to buy token for > S - BROKER tries to bridge the two offers This currently results in `tecINSUFFICIENT_FUNDS`, but should not because BROKER is not spending any funds in this transaction, beyond the transaction fee. When trading an NFT using IOUs, and when the issuer of the IOU has any non-zero value set for TransferFee on their account via AccountSet (not a TransferFee on the NFT), and when the sale amount is equal to the total balance of that IOU that the buyer has, the resulting balance for the issuer of the IOU will become positive. This means that the buyer of the NFT was supposed to have caused a certain amount of IOU to be burned. That amount was unable to be burned because the buyer couldn't cover it. This results in the buyer owing this amount back to the issuer. In a real world scenario, this is appropriate and can be settled off-chain. Currency issuers could not make offers for NFTs using their own currency, receiving `tecINSUFFICIENT_FUNDS` if they tried to do so. With this fix, they are now able to buy/sell NFTs using their own currency. --- src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp | 74 +- src/ripple/app/tx/impl/NFTokenCreateOffer.cpp | 31 +- src/ripple/ledger/View.h | 5 + src/test/app/NFToken_test.cpp | 1650 +++++++++++++---- 4 files changed, 1339 insertions(+), 421 deletions(-) diff --git a/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp index 07fe9957a76..c335f8d28fd 100644 --- a/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp +++ b/src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp @@ -168,10 +168,21 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) dest.has_value() && *dest != ctx.tx[sfAccount]) return tecNO_PERMISSION; } + // The account offering to buy must have funds: + // + // After this amendment, we allow an IOU issuer to buy an NFT with their + // own currency auto const needed = bo->at(sfAmount); - - if (accountHolds( + if (ctx.view.rules().enabled(fixUnburnableNFToken)) + { + if (accountFunds( + ctx.view, (*bo)[sfOwner], needed, fhZERO_IF_FROZEN, ctx.j) < + needed) + return tecINSUFFICIENT_FUNDS; + } + else if ( + accountHolds( ctx.view, (*bo)[sfOwner], needed.getCurrency(), @@ -206,15 +217,39 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) // The account offering to buy must have funds: auto const needed = so->at(sfAmount); - - if (accountHolds( - ctx.view, - ctx.tx[sfAccount], - needed.getCurrency(), - needed.getIssuer(), - fhZERO_IF_FROZEN, - ctx.j) < needed) - return tecINSUFFICIENT_FUNDS; + if (!ctx.view.rules().enabled(fixUnburnableNFToken)) + { + if (accountHolds( + ctx.view, + ctx.tx[sfAccount], + needed.getCurrency(), + needed.getIssuer(), + fhZERO_IF_FROZEN, + ctx.j) < needed) + return tecINSUFFICIENT_FUNDS; + } + else if (!bo) + { + // After this amendment, we allow buyers to buy with their own + // issued currency. + // + // In the case of brokered mode, this check is essentially + // redundant, since we have already confirmed that buy offer is > + // than the sell offer, and that the buyer can cover the buy + // offer. + // + // We also _must not_ check the tx submitter in brokered + // mode, because then we are confirming that the broker can + // cover what the buyer will pay, which doesn't make sense, causes + // an unncessary tec, and is also resolved with this amendment. + if (accountFunds( + ctx.view, + ctx.tx[sfAccount], + needed, + fhZERO_IF_FROZEN, + ctx.j) < needed) + return tecINSUFFICIENT_FUNDS; + } } return tesSUCCESS; @@ -230,7 +265,22 @@ NFTokenAcceptOffer::pay( if (amount < beast::zero) return tecINTERNAL; - return accountSend(view(), from, to, amount, j_); + auto const result = accountSend(view(), from, to, amount, j_); + + // After this amendment, if any payment would cause a non-IOU-issuer to + // have a negative balance, or an IOU-issuer to have a positive balance in + // their own currency, we know that something went wrong. This was + // originally found in the context of IOU transfer fees. Since there are + // several payouts in this tx, just confirm that the end state is OK. + if (!view().rules().enabled(fixUnburnableNFToken)) + return result; + if (result != tesSUCCESS) + return result; + if (accountFunds(view(), from, amount, fhZERO_IF_FROZEN, j_).signum() < 0) + return tecINSUFFICIENT_FUNDS; + if (accountFunds(view(), to, amount, fhZERO_IF_FROZEN, j_).signum() < 0) + return tecINSUFFICIENT_FUNDS; + return tesSUCCESS; } TER diff --git a/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp b/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp index 695efdd0aa4..ff8668e4488 100644 --- a/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp +++ b/src/ripple/app/tx/impl/NFTokenCreateOffer.cpp @@ -153,15 +153,28 @@ NFTokenCreateOffer::preclaim(PreclaimContext const& ctx) // offer may later become unfunded. if (!isSellOffer) { - auto const funds = accountHolds( - ctx.view, - ctx.tx[sfAccount], - amount.getCurrency(), - amount.getIssuer(), - FreezeHandling::fhZERO_IF_FROZEN, - ctx.j); - - if (funds.signum() <= 0) + // After this amendment, we allow an IOU issuer to make a buy offer + // using their own currency. + if (ctx.view.rules().enabled(fixUnburnableNFToken)) + { + if (accountFunds( + ctx.view, + ctx.tx[sfAccount], + amount, + FreezeHandling::fhZERO_IF_FROZEN, + ctx.j) + .signum() <= 0) + return tecUNFUNDED_OFFER; + } + else if ( + accountHolds( + ctx.view, + ctx.tx[sfAccount], + amount.getCurrency(), + amount.getIssuer(), + FreezeHandling::fhZERO_IF_FROZEN, + ctx.j) + .signum() <= 0) return tecUNFUNDED_OFFER; } diff --git a/src/ripple/ledger/View.h b/src/ripple/ledger/View.h index ee917115515..24a647c768d 100644 --- a/src/ripple/ledger/View.h +++ b/src/ripple/ledger/View.h @@ -97,6 +97,11 @@ accountHolds( FreezeHandling zeroIfFrozen, beast::Journal j); +// Returns the amount an account can spend of the currency type saDefault, or +// returns saDefault if this account is the issuer of the the currency in +// question. Should be used in favor of accountHolds when questioning how much +// an account can spend while also allowing currency issuers to spend +// unlimited amounts of their own currency (since they can always issue more). [[nodiscard]] STAmount accountFunds( ReadView const& view, diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index 842f3f76cc8..33d725e5a17 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -3827,497 +3827,586 @@ class NFToken_test : public beast::unit_test::suite using namespace test::jtx; - Env env{*this, features}; - - // The most important thing to explore here is the way funds are - // assigned from the buyer to... - // o the Seller, - // o the Broker, and - // o the Issuer (in the case of a transfer fee). - - Account const issuer{"issuer"}; - Account const minter{"minter"}; - Account const buyer{"buyer"}; - Account const broker{"broker"}; - Account const gw{"gw"}; - IOU const gwXAU(gw["XAU"]); - - env.fund(XRP(1000), issuer, minter, buyer, broker, gw); - env.close(); - - env(trust(issuer, gwXAU(2000))); - env(trust(minter, gwXAU(2000))); - env(trust(buyer, gwXAU(2000))); - env(trust(broker, gwXAU(2000))); - env.close(); - - env(token::setMinter(issuer, minter)); - env.close(); - - // Lambda to check owner count of all accounts is one. - auto checkOwnerCountIsOne = - [this, &env]( - std::initializer_list> - accounts, - int line) { - for (Account const& acct : accounts) - { - if (std::uint32_t ownerCount = this->ownerCount(env, acct); - ownerCount != 1) - { - std::stringstream ss; - ss << "Account " << acct.human() - << " expected ownerCount == 1. Got " << ownerCount; - fail(ss.str(), __FILE__, line); - } - } - }; - - // Lambda that mints an NFT and returns the nftID. - auto mintNFT = [&env, &issuer, &minter](std::uint16_t xferFee = 0) { - uint256 const nftID = - token::getNextID(env, issuer, 0, tfTransferable, xferFee); - env(token::mint(minter, 0), - token::issuer(issuer), - token::xferFee(xferFee), - txflags(tfTransferable)); - env.close(); - return nftID; - }; - - // o Seller is selling for zero XRP. - // o Broker charges no fee. - // o No transfer fee. - // - // Since minter is selling for zero the currency must be XRP. + for (auto const& tweakedFeatures : + {features - fixUnburnableNFToken, features | fixUnburnableNFToken}) { - checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); + Env env{*this, tweakedFeatures}; - uint256 const nftID = mintNFT(); + // 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). - // 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(); + 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"]); - // 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.fund(XRP(1000), issuer, minter, buyer, broker, gw); 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(trust(issuer, gwXAU(2000))); + env(trust(minter, gwXAU(2000))); + env(trust(buyer, gwXAU(2000))); + env(trust(broker, gwXAU(2000))); env.close(); - // Note that minter's XRP balance goes up even though they - // requested XRP(0). - BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(1)); - BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); - BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); - BEAST_EXPECT(env.balance(issuer) == issuerBalance); - - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); + env(token::setMinter(issuer, minter)); 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__); + // Lambda to check owner count of all accounts is one. + auto checkOwnerCountIsOne = + [this, &env]( + std::initializer_list> + accounts, + int line) { + for (Account const& acct : accounts) + { + if (std::uint32_t ownerCount = + this->ownerCount(env, acct); + ownerCount != 1) + { + std::stringstream ss; + ss << "Account " << acct.human() + << " expected ownerCount == 1. Got " + << ownerCount; + fail(ss.str(), __FILE__, line); + } + } + }; - uint256 const nftID = mintNFT(); + // 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; + }; - // 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(); + // 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__); - // 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(); + uint256 const nftID = mintNFT(); - // 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(); + // 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(); - auto const minterBalance = env.balance(minter); - auto const buyerBalance = env.balance(buyer); - auto const brokerBalance = env.balance(broker); - auto const issuerBalance = env.balance(issuer); + // 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 charges a 0.5 XRP brokerFee. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(XRP(0.5))); - 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); - // Note that minter's XRP balance goes up even though they - // requested XRP(0). - BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); - BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); - BEAST_EXPECT( - env.balance(broker) == brokerBalance + XRP(0.5) - drops(10)); - BEAST_EXPECT(env.balance(issuer) == issuerBalance); + // Broker charges no brokerFee. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex)); + env.close(); - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); - } + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(1)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance); - // 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__); + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } - uint256 const nftID = mintNFT(maxTransferFee); + // 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__); - // 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(); + uint256 const nftID = mintNFT(); - // 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(); + // 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(); - auto const minterBalance = env.balance(minter); - auto const buyerBalance = env.balance(buyer); - auto const brokerBalance = env.balance(broker); - auto const issuerBalance = env.balance(issuer); + // 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 charges no brokerFee. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex)); - 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(); - // Note that minter's XRP balance goes up even though they - // requested XRP(0). - BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); - BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); - BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); - BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.5)); + auto const minterBalance = env.balance(minter); + auto const buyerBalance = env.balance(buyer); + auto const brokerBalance = env.balance(broker); + auto const issuerBalance = env.balance(issuer); - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); - } + // Broker charges a 0.5 XRP brokerFee. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(XRP(0.5))); + 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__); + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT( + env.balance(broker) == + brokerBalance + XRP(0.5) - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } - uint256 const nftID = mintNFT(maxTransferFee); + // 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__); - // 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(); + uint256 const nftID = mintNFT(maxTransferFee); - // 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(); + // 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(); - auto const minterBalance = env.balance(minter); - auto const buyerBalance = env.balance(buyer); - auto const brokerBalance = env.balance(broker); - auto const issuerBalance = env.balance(issuer); + // 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 charges a 0.75 XRP brokerFee. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(XRP(0.75))); - 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); - // Note that, with a 50% transfer fee, issuer gets 1/2 of what's - // left _after_ broker takes their fee. minter gets the remainder - // after both broker and minter take their cuts - BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.125)); - BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); - BEAST_EXPECT( - env.balance(broker) == brokerBalance + XRP(0.75) - drops(10)); - BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.125)); + // Broker charges no brokerFee. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex)); + env.close(); - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); - } + // Note that minter's XRP balance goes up even though they + // requested XRP(0). + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.5)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT(env.balance(broker) == brokerBalance - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.5)); - // Lambda to set the balance of all passed in accounts to gwXAU(1000). - auto setXAUBalance_1000 = - [this, &gw, &gwXAU, &env]( - std::initializer_list> - accounts, - int line) { - for (Account const& acct : accounts) - { - static const auto xau1000 = gwXAU(1000); - auto const balance = env.balance(acct, gwXAU); - if (balance < xau1000) - { - env(pay(gw, acct, xau1000 - balance)); - env.close(); - } - else if (balance > xau1000) - { - env(pay(acct, gw, balance - xau1000)); - env.close(); - } - if (env.balance(acct, gwXAU) != xau1000) - { - std::stringstream ss; - ss << "Unable to set " << acct.human() - << " account balance to gwXAU(1000)"; - this->fail(ss.str(), __FILE__, line); - } - } - }; + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); + env.close(); + } - // The buyer and seller have identical amounts and there is no - // transfer fee. - { - checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); - setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); + // 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(); + 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(1000)), - txflags(tfSellNFToken)); - env.close(); + // 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 an offer for more XAU than they currently own. + // 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, gwXAU(1001)), + env(token::createOffer(buyer, nftID, XRP(1)), token::owner(minter)); env.close(); - // broker attempts to broker the offers but cannot. + 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), - ter(tecINSUFFICIENT_FUNDS)); + token::brokerFee(XRP(0.75))); env.close(); - // Cancel buyer's bad offer so the next test starts in a - // clean state. - env(token::cancelOffer(buyer, {buyOfferIndex})); + // Note that, with a 50% transfer fee, issuer gets 1/2 of what's + // left _after_ broker takes their fee. minter gets the + // remainder after both broker and minter take their cuts + BEAST_EXPECT(env.balance(minter) == minterBalance + XRP(0.125)); + BEAST_EXPECT(env.balance(buyer) == buyerBalance - XRP(1)); + BEAST_EXPECT( + env.balance(broker) == + brokerBalance + XRP(0.75) - drops(10)); + BEAST_EXPECT(env.balance(issuer) == issuerBalance + XRP(0.125)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(buyer, nftID)); env.close(); } + + // Lambda to set the balance of all passed in accounts to + // gwXAU(amount). + auto setXAUBalance = + [this, &gw, &gwXAU, &env]( + std::initializer_list> + 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. { - // buyer creates an offer for less that what minter is asking. + 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(999)), + env(token::createOffer(buyer, nftID, gwXAU(1000)), token::owner(minter)); env.close(); - // broker attempts to broker the offers but cannot. + // Broker attempts to charge a brokerFee but cannot. env(token::brokerOffers( broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(0.1)), ter(tecINSUFFICIENT_PAYMENT)); env.close(); - // Cancel buyer's bad offer so the next test starts in a - // clean state. - env(token::cancelOffer(buyer, {buyOfferIndex})); + // 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(); } - // 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(); + // 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__); - // Broker attempts to charge a brokerFee but cannot. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(gwXAU(0.1)), - ter(tecINSUFFICIENT_PAYMENT)); - env.close(); + uint256 const nftID = mintNFT(maxTransferFee); - // 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)); + // 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(); - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); - } + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); - // seller offers more than buyer is asking. - // There are both transfer and broker fees. - { - checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); - setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); + // 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(); - uint256 const nftID = mintNFT(maxTransferFee); + // broker attempts to broker the offers but cannot. + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + ter(tecINSUFFICIENT_PAYMENT)); + env.close(); - // 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. + // 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(1001)), + env(token::createOffer(buyer, nftID, gwXAU(1000)), token::owner(minter)); env.close(); - // broker attempts to broker the offers but cannot. + // 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), - ter(tecINSUFFICIENT_FUNDS)); + token::brokerFee(gwXAU(100))); env.close(); - // Cancel buyer's bad offer so the next test starts in a - // clean state. - env(token::cancelOffer(buyer, {buyOfferIndex})); + 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. { - // buyer creates an offer for less that what minter is asking. + 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(899)), + env(token::createOffer(buyer, nftID, gwXAU(1000)), token::owner(minter)); env.close(); - // broker attempts to broker the offers but cannot. + // 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), - ter(tecINSUFFICIENT_PAYMENT)); + token::brokerFee(gwXAU(50))); env.close(); - // Cancel buyer's bad offer so the next test starts in a - // clean state. - env(token::cancelOffer(buyer, {buyOfferIndex})); + 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(); } - // buyer creates a large enough offer. - uint256 const buyOfferIndex = - keylet::nftoffer(buyer, env.seq(buyer)).key; - env(token::createOffer(buyer, nftID, gwXAU(1000)), - token::owner(minter)); - env.close(); - - // Broker attempts to charge a brokerFee larger than the - // difference between the two offers but cannot. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(gwXAU(101)), - ter(tecINSUFFICIENT_PAYMENT)); - env.close(); - - // broker charges the full difference between the two offers and - // succeeds. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(gwXAU(100))); - env.close(); - - BEAST_EXPECT(ownerCount(env, issuer) == 1); - BEAST_EXPECT(ownerCount(env, minter) == 1); - BEAST_EXPECT(ownerCount(env, buyer) == 2); - BEAST_EXPECT(ownerCount(env, broker) == 1); - BEAST_EXPECT(env.balance(issuer, gwXAU) == gwXAU(1450)); - BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1450)); - BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(0)); - BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(1100)); - - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); - } - // seller offers more than buyer is asking. - // There are both transfer and broker fees, but broker takes less than - // the maximum. - { - checkOwnerCountIsOne({issuer, minter, buyer, broker}, __LINE__); - setXAUBalance_1000({issuer, minter, buyer, broker}, __LINE__); - - uint256 const nftID = mintNFT(maxTransferFee / 2); // 25% - - // minter creates their offer. - uint256 const minterOfferIndex = - keylet::nftoffer(minter, env.seq(minter)).key; - env(token::createOffer(minter, nftID, gwXAU(900)), - txflags(tfSellNFToken)); - env.close(); - - // buyer creates a large enough offer. - uint256 const buyOfferIndex = - keylet::nftoffer(buyer, env.seq(buyer)).key; - env(token::createOffer(buyer, nftID, gwXAU(1000)), - token::owner(minter)); - env.close(); - - // broker charges half difference between the two offers and - // succeeds. 25% of the remaining difference goes to issuer. - // The rest goes to minter. - env(token::brokerOffers(broker, buyOfferIndex, minterOfferIndex), - token::brokerFee(gwXAU(50))); - env.close(); + // 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(); - 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)); + // 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(); - // Burn the NFT so the next test starts with a clean state. - env(token::burn(buyer, nftID)); - env.close(); + if (tweakedFeatures[fixUnburnableNFToken]) + { + 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(); + } + else + { + env(token::brokerOffers( + broker, buyOfferIndex, minterOfferIndex), + token::brokerFee(gwXAU(50)), + ter(tecINSUFFICIENT_FUNDS)); + env.close(); + BEAST_EXPECT(ownerCount(env, issuer) == 1); + BEAST_EXPECT(ownerCount(env, minter) == 3); + 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(1000)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(broker, gwXAU) == gwXAU(500)); + + // Burn the NFT so the next test starts with a clean state. + env(token::burn(minter, nftID)); + env.close(); + } + } } } @@ -4823,9 +4912,14 @@ class NFToken_test : public beast::unit_test::suite Account const gw{"gw"}; IOU const gwXAU(gw["XAU"]); - // Test both with and without fixNFTokenNegOffer + // Test both with and without fixNFTokenNegOffer and + // fixUnburnableNFToken. Need to turn off fixUnburnableNFToken as well + // because that amendment came later and addressed the acceptance + // side of this issue. for (auto const& tweakedFeatures : - {features - fixNFTokenNegOffer - featureNonFungibleTokensV1_1, + {features - fixNFTokenNegOffer - featureNonFungibleTokensV1_1 - + fixUnburnableNFToken, + features - fixNFTokenNegOffer - featureNonFungibleTokensV1_1, features | fixNFTokenNegOffer}) { // There was a bug in the initial NFT implementation that @@ -4914,8 +5008,10 @@ class NFToken_test : public beast::unit_test::suite env.close(); } { - // 1. If fixNFTokenNegOffer is NOT enabled get tecSUCCESS. - // 2. If fixNFTokenNegOffer IS enabled get tecOBJECT_NOT_FOUND. + // 1. If fixNFTokenNegOffer is enabled get tecOBJECT_NOT_FOUND + // 2. If it is not enabled, but fixUnburnableNFToken is + // enabled, get tecOBJECT_NOT_FOUND. + // 3. If neither are enabled, get tesSUCCESS. TER const offerAcceptTER = tweakedFeatures[fixNFTokenNegOffer] ? static_cast(tecOBJECT_NOT_FOUND) : static_cast(tesSUCCESS); @@ -5047,6 +5143,757 @@ class NFToken_test : public beast::unit_test::suite } } + void + testIOUWithTransferFee(FeatureBitset features) + { + using namespace test::jtx; + + testcase("Payments with IOU transfer fees"); + + for (auto const& tweakedFeatures : + {features - fixUnburnableNFToken, features | fixUnburnableNFToken}) + { + Env env{*this, tweakedFeatures}; + + 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 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(tesSUCCESS))); + env.close(); + return offerID; + }; + + auto createSellOffer = + [&env]( + Account const& offerer, + uint256 const& nftID, + STAmount const& amount, + std::optional 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(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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptSellOffer(buyer, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-20)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-1000)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(20)); + } + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptBuyOffer(minter, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-20)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-1000)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(20)); + } + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptSellOffer(buyer, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(995)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-14.9)); + BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-995)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(14.9)); + } + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptBuyOffer(minter, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(995)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-14.9)); + BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-995)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(14.9)); + } + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecINSUFFICIENT_FUNDS); + env(token::acceptSellOffer(gw, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-1000)); + } + else + expectInitialState(); + } + { + // Gateway attempts to buy NFT with their own IOU - no + // transfer fee is calculated here (buyside) + reinitializeTrustLineBalances(); + + auto const nftID = mintNFT(minter); + auto const offerTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecUNFUNDED_OFFER); + auto const offerID = + createBuyOffer(gw, minter, nftID, gwXAU(1000), {offerTER}); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecOBJECT_NOT_FOUND); + env(token::acceptBuyOffer(minter, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(1000)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-1000)); + } + else + expectInitialState(); + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecINSUFFICIENT_FUNDS); + env(token::acceptSellOffer(gw, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(5000)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-5000)); + } + else + expectInitialState(); + } + { + // Gateway attempts to buy NFT with their own IOU for more + // than minter trusts (buyside) + reinitializeTrustLineBalances(); + + auto const nftID = mintNFT(minter); + auto const offerTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecUNFUNDED_OFFER); + auto const offerID = + createBuyOffer(gw, minter, nftID, gwXAU(5000), {offerTER}); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tesSUCCESS) + : static_cast(tecOBJECT_NOT_FOUND); + env(token::acceptBuyOffer(minter, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(5000)); + BEAST_EXPECT( + env.balance(gw, minter["XAU"]) == gwXAU(-5000)); + } + else + expectInitialState(); + } + { + // 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(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(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(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(tecUNFUNDED_OFFER)}); + env(token::acceptBuyOffer(minter, offerID), + ter(static_cast(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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptSellOffer(buyer, offerID), ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(30)); + BEAST_EXPECT( + env.balance(secondarySeller, gwXAU) == gwXAU(970)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-20)); + BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-30)); + BEAST_EXPECT( + env.balance(gw, secondarySeller["XAU"]) == gwXAU(-970)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(20)); + } + } + { + // 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)); + auto const sellTER = tweakedFeatures[fixUnburnableNFToken] + ? static_cast(tecINSUFFICIENT_FUNDS) + : static_cast(tesSUCCESS); + env(token::acceptBuyOffer(secondarySeller, offerID), + ter(sellTER)); + env.close(); + + if (tweakedFeatures[fixUnburnableNFToken]) + expectInitialState(); + else + { + BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(30)); + BEAST_EXPECT( + env.balance(secondarySeller, gwXAU) == gwXAU(970)); + BEAST_EXPECT(env.balance(buyer, gwXAU) == gwXAU(-20)); + BEAST_EXPECT(env.balance(gw, minter["XAU"]) == gwXAU(-30)); + BEAST_EXPECT( + env.balance(gw, secondarySeller["XAU"]) == gwXAU(-970)); + BEAST_EXPECT(env.balance(gw, buyer["XAU"]) == gwXAU(20)); + } + } + { + // 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 testWithFeats(FeatureBitset features) { @@ -5076,6 +5923,7 @@ class NFToken_test : public beast::unit_test::suite testNFTokenDeleteAccount(features); testNftXxxOffers(features); testFixNFTokenNegOffer(features); + testIOUWithTransferFee(features); } public: @@ -5086,6 +5934,8 @@ class NFToken_test : public beast::unit_test::suite FeatureBitset const all{supported_amendments()}; FeatureBitset const fixNFTDir{fixNFTokenDirV1}; + // TODO too many tests are being run - ths fixNFTDir check should be + // pushed into the tests that use it testWithFeats(all - fixNFTDir); testWithFeats(all - disallowIncoming); testWithFeats(all);