Skip to content

Commit db8e8ce

Browse files
committed
Prevent brokered sale of NFToken to owner:
Fixes #4374
1 parent ce42b74 commit db8e8ce

File tree

2 files changed

+120
-5
lines changed

2 files changed

+120
-5
lines changed

src/ripple/app/tx/impl/NFTokenAcceptOffer.cpp

+6
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx)
107107
if ((*bo)[sfAmount].issue() != (*so)[sfAmount].issue())
108108
return tecNFTOKEN_BUY_SELL_MISMATCH;
109109

110+
// The two offers may not form a loop. A broker may not sell the
111+
// token to the current owner of the token.
112+
if (ctx.view.rules().enabled(fixUnburnableNFToken) &&
113+
((*bo)[sfOwner] == (*so)[sfOwner]))
114+
return tecCANT_ACCEPT_OWN_NFTOKEN_OFFER;
115+
110116
// Ensure that the buyer is willing to pay at least as much as the
111117
// seller is requesting:
112118
if ((*so)[sfAmount] > (*bo)[sfAmount])

src/test/app/NFToken_test.cpp

+114-5
Original file line numberDiff line numberDiff line change
@@ -4607,7 +4607,7 @@ class NFToken_test : public beast::unit_test::suite
46074607
txflags(tfTransferable));
46084608
env.close();
46094609

4610-
// At the momement issuer and minter cannot delete themselves.
4610+
// At the moment issuer and minter cannot delete themselves.
46114611
// o issuer has an issued NFT in the ledger.
46124612
// o minter owns an NFT.
46134613
env(acctdelete(issuer, daria), fee(XRP(50)), ter(tecHAS_OBLIGATIONS));
@@ -5888,6 +5888,115 @@ class NFToken_test : public beast::unit_test::suite
58885888
}
58895889
}
58905890

5891+
void
5892+
testBrokeredSaleToSelf(FeatureBitset features)
5893+
{
5894+
// There was a bug that if an account had...
5895+
//
5896+
// 1. An NFToken, and
5897+
// 2. An offer on the ledger to buy that same token, and
5898+
// 3. Also an offer of the ledger to sell that same token,
5899+
//
5900+
// Then someone could broker the two offers. This would result in
5901+
// the NFToken being bought and returned to the original owner and
5902+
// the broker pocketing the profit.
5903+
//
5904+
// This unit test verifies that the fixUnburnableNFToken amendment
5905+
// fixes that bug.
5906+
testcase("Brokered sale to self");
5907+
5908+
using namespace test::jtx;
5909+
5910+
Account const alice{"alice"};
5911+
Account const bob{"bob"};
5912+
Account const broker{"broker"};
5913+
5914+
Env env{*this, features};
5915+
env.fund(XRP(10000), alice, bob, broker);
5916+
env.close();
5917+
5918+
// For this scenario to occur we need the following steps:
5919+
//
5920+
// 1. alice mints NFT.
5921+
// 2. bob creates a buy offer for it for 5 XRP.
5922+
// 3. alice decides to gift the NFT to bob for 0.
5923+
// creating a sell offer (hopefully using a destination too)
5924+
// 4. Bob accepts the sell offer, because it is better than
5925+
// paying 5 XRP.
5926+
// 5. At this point, bob has the NFT and still has their buy
5927+
// offer from when they did not have the NFT! This is because
5928+
// the order book is not cleared when an NFT changes hands.
5929+
// 6. Now that Bob owns the NFT, he cannot create new buy offers.
5930+
// However he still has one left over from when he did not own
5931+
// it. He can create new sell offers and does.
5932+
// 7. Now that bob has both a buy and a sell offer for the same NFT,
5933+
// a broker can sell the NFT that bob owns to bob and pocket the
5934+
// difference.
5935+
uint256 const nftId{token::getNextID(env, alice, 0u, tfTransferable)};
5936+
env(token::mint(alice, 0u), txflags(tfTransferable));
5937+
env.close();
5938+
5939+
// Bob creates a buy offer for 5 XRP. Alice creates a sell offer
5940+
// for 0 XRP.
5941+
uint256 const bobBuyOfferIndex =
5942+
keylet::nftoffer(bob, env.seq(bob)).key;
5943+
env(token::createOffer(bob, nftId, XRP(5)), token::owner(alice));
5944+
5945+
uint256 const aliceSellOfferIndex =
5946+
keylet::nftoffer(alice, env.seq(alice)).key;
5947+
env(token::createOffer(alice, nftId, XRP(0)),
5948+
token::destination(bob),
5949+
txflags(tfSellNFToken));
5950+
env.close();
5951+
5952+
// bob accepts alice's offer but forgets to remove the old buy offer.
5953+
env(token::acceptSellOffer(bob, aliceSellOfferIndex));
5954+
env.close();
5955+
5956+
// Note that bob still has a buy offer on the books.
5957+
BEAST_EXPECT(env.le(keylet::nftoffer(bobBuyOfferIndex)));
5958+
5959+
// Bob creates a sell offer for the gift NFT from alice.
5960+
uint256 const bobSellOfferIndex =
5961+
keylet::nftoffer(bob, env.seq(bob)).key;
5962+
env(token::createOffer(bob, nftId, XRP(4)), txflags(tfSellNFToken));
5963+
env.close();
5964+
5965+
// bob now has a buy offer and a sell offer on the books. A broker
5966+
// spots this and swoops in to make a profit.
5967+
BEAST_EXPECT(nftCount(env, bob) == 1);
5968+
auto const bobsPriorBalance = env.balance(bob);
5969+
auto const brokersPriorBalance = env.balance(broker);
5970+
TER expectTer = features[fixUnburnableNFToken]
5971+
? TER(tecCANT_ACCEPT_OWN_NFTOKEN_OFFER)
5972+
: TER(tesSUCCESS);
5973+
env(token::brokerOffers(broker, bobBuyOfferIndex, bobSellOfferIndex),
5974+
token::brokerFee(XRP(1)),
5975+
ter(expectTer));
5976+
env.close();
5977+
5978+
if (expectTer == tesSUCCESS)
5979+
{
5980+
// bob should still have the NFT from alice, but be XRP(1) poorer.
5981+
// broker should be almost XRP(1) richer because they also paid a
5982+
// transaction fee.
5983+
BEAST_EXPECT(nftCount(env, bob) == 1);
5984+
BEAST_EXPECT(env.balance(bob) == bobsPriorBalance - XRP(1));
5985+
BEAST_EXPECT(
5986+
env.balance(broker) ==
5987+
brokersPriorBalance + XRP(1) - drops(10));
5988+
}
5989+
else
5990+
{
5991+
// A tec result was returned, so no state should change other
5992+
// than the broker burning their transaction fee.
5993+
BEAST_EXPECT(nftCount(env, bob) == 1);
5994+
BEAST_EXPECT(env.balance(bob) == bobsPriorBalance);
5995+
BEAST_EXPECT(
5996+
env.balance(broker) == brokersPriorBalance - drops(10));
5997+
}
5998+
}
5999+
58916000
void
58926001
testWithFeats(FeatureBitset features)
58936002
{
@@ -5918,6 +6027,7 @@ class NFToken_test : public beast::unit_test::suite
59186027
testNftXxxOffers(features);
59196028
testFixNFTokenNegOffer(features);
59206029
testIOUWithTransferFee(features);
6030+
testBrokeredSaleToSelf(features);
59216031
}
59226032

59236033
public:
@@ -5928,10 +6038,9 @@ class NFToken_test : public beast::unit_test::suite
59286038
FeatureBitset const all{supported_amendments()};
59296039
FeatureBitset const fixNFTDir{fixNFTokenDirV1};
59306040

5931-
// TODO too many tests are being run - ths fixNFTDir check should be
5932-
// pushed into the tests that use it
5933-
testWithFeats(all - fixNFTDir);
5934-
testWithFeats(all - disallowIncoming);
6041+
testWithFeats(all - fixNFTDir - fixUnburnableNFToken);
6042+
testWithFeats(all - disallowIncoming - fixUnburnableNFToken);
6043+
testWithFeats(all - fixUnburnableNFToken);
59356044
testWithFeats(all);
59366045
}
59376046
};

0 commit comments

Comments
 (0)