diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index f458b57219..df4af23e96 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -32,6 +32,7 @@ // If you add an amendment here, then do not forget to increment `numFeatures` // in include/xrpl/protocol/Feature.h. +XRPL_FIX (EnforceNFTokenTrustlineV2, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (AMMv1_3, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionedDEX, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Batch, Supported::yes, VoteBehavior::DefaultNo) diff --git a/src/test/app/Freeze_test.cpp b/src/test/app/Freeze_test.cpp index b28e794688..8c2021d657 100644 --- a/src/test/app/Freeze_test.cpp +++ b/src/test/app/Freeze_test.cpp @@ -1885,6 +1885,31 @@ class Freeze_test : public beast::unit_test::suite env.close(); } + // Testing A1 nft buy offer when A2 deep frozen by issuer + if (features[featureDeepFreeze] && + features[fixEnforceNFTokenTrustlineV2]) + { + env(trust(G1, A2["USD"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + uint256 const nftID{token::getNextID(env, A2, 0u, tfTransferable)}; + env(token::mint(A2, 0), txflags(tfTransferable)); + env.close(); + + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(10)), token::owner(A2)); + env.close(); + + env(token::acceptBuyOffer(A2, buyIdx), ter(tecFROZEN)); + env.close(); + + env(trust(G1, A2["USD"](0), tfClearFreeze | tfClearDeepFreeze)); + env.close(); + + env(token::acceptBuyOffer(A2, buyIdx)); + env.close(); + } + // Testing A2 nft offer sell when A2 frozen by currency holder { auto const sellOfferIndex = createNFTSellOffer(env, A2, USD(10)); @@ -1944,6 +1969,68 @@ class Freeze_test : public beast::unit_test::suite env(trust(A2, limit, tfClearFreeze | tfClearDeepFreeze)); env.close(); } + + // Testing brokered offer acceptance + if (features[featureDeepFreeze] && + features[fixEnforceNFTokenTrustlineV2]) + { + Account broker{"broker"}; + env.fund(XRP(10000), broker); + env.close(); + env(trust(G1, broker["USD"](1000), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + uint256 const nftID{token::getNextID(env, A2, 0u, tfTransferable)}; + env(token::mint(A2, 0), txflags(tfTransferable)); + env.close(); + + uint256 const sellIdx = keylet::nftoffer(A2, env.seq(A2)).key; + env(token::createOffer(A2, nftID, USD(10)), txflags(tfSellNFToken)); + env.close(); + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(11)), token::owner(A2)); + env.close(); + + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecFROZEN)); + env.close(); + } + + // Testing transfer fee + if (features[featureDeepFreeze] && + features[fixEnforceNFTokenTrustlineV2]) + { + Account minter{"minter"}; + env.fund(XRP(10000), minter); + env.close(); + env(trust(G1, minter["USD"](1000))); + env.close(); + + uint256 const nftID{ + token::getNextID(env, minter, 0u, tfTransferable, 1u)}; + env(token::mint(minter, 0), + token::xferFee(1u), + txflags(tfTransferable)); + env.close(); + + uint256 const minterSellIdx = + keylet::nftoffer(minter, env.seq(minter)).key; + env(token::createOffer(minter, nftID, drops(1)), + txflags(tfSellNFToken)); + env.close(); + env(token::acceptSellOffer(A2, minterSellIdx)); + env.close(); + + uint256 const sellIdx = keylet::nftoffer(A2, env.seq(A2)).key; + env(token::createOffer(A2, nftID, USD(100)), + txflags(tfSellNFToken)); + env.close(); + env(trust(G1, minter["USD"](1000), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + env(token::acceptSellOffer(A1, sellIdx), ter(tecFROZEN)); + env.close(); + } } // Helper function to extract trustline flags from open ledger @@ -2021,10 +2108,16 @@ public: using namespace test::jtx; auto const sa = supported_amendments(); testAll( - sa - featureFlowCross - featureDeepFreeze - featurePermissionedDEX); - testAll(sa - featureFlowCross - featurePermissionedDEX); - testAll(sa - featureDeepFreeze - featurePermissionedDEX); - testAll(sa - featurePermissionedDEX); + sa - featureFlowCross - featureDeepFreeze - featurePermissionedDEX - + fixEnforceNFTokenTrustlineV2); + testAll( + sa - featureFlowCross - featurePermissionedDEX - + fixEnforceNFTokenTrustlineV2); + testAll( + sa - featureDeepFreeze - featurePermissionedDEX - + fixEnforceNFTokenTrustlineV2); + testAll(sa - featurePermissionedDEX - fixEnforceNFTokenTrustlineV2); + testAll(sa - fixEnforceNFTokenTrustlineV2); testAll(sa); } }; diff --git a/src/test/app/NFTokenAuth_test.cpp b/src/test/app/NFTokenAuth_test.cpp new file mode 100644 index 0000000000..9558a03f7a --- /dev/null +++ b/src/test/app/NFTokenAuth_test.cpp @@ -0,0 +1,624 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include + +#include + +namespace ripple { + +class NFTokenAuth_test : public beast::unit_test::suite +{ + auto + mintAndOfferNFT( + test::jtx::Env& env, + test::jtx::Account const& account, + test::jtx::PrettyAmount const& currency, + uint32_t xfee = 0u) + { + using namespace test::jtx; + auto const nftID{ + token::getNextID(env, account, 0u, tfTransferable, xfee)}; + env(token::mint(account, 0), + token::xferFee(xfee), + txflags(tfTransferable)); + env.close(); + + auto const sellIdx = keylet::nftoffer(account, env.seq(account)).key; + env(token::createOffer(account, nftID, currency), + txflags(tfSellNFToken)); + env.close(); + + return std::make_tuple(nftID, sellIdx); + } + +public: + void + testBuyOffer_UnauthorizedSeller(FeatureBitset features) + { + testcase("Unauthorized seller tries to accept buy offer"); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + + auto const [nftID, _] = mintAndOfferNFT(env, A2, drops(1)); + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + + // It should be possible to create a buy offer even if NFT owner is not + // authorized + env(token::createOffer(A1, nftID, USD(10)), token::owner(A2)); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: G1 requires authorization of A2, no trust line exists + env(token::acceptBuyOffer(A2, buyIdx), ter(tecNO_LINE)); + env.close(); + + // trust line created, but not authorized + env(trust(A2, limit)); + + // test: G1 requires authorization of A2 + env(token::acceptBuyOffer(A2, buyIdx), ter(tecNO_AUTH)); + env.close(); + } + else + { + // Old behavior: it is possible to sell tokens and receive IOUs + // without the authorization + env(token::acceptBuyOffer(A2, buyIdx)); + env.close(); + + BEAST_EXPECT(env.balance(A2, USD) == USD(10)); + } + } + + void + testCreateBuyOffer_UnauthorizedBuyer(FeatureBitset features) + { + testcase("Unauthorized buyer tries to create buy offer"); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const [nftID, _] = mintAndOfferNFT(env, A2, drops(1)); + + // test: check that buyer can't make an offer if they're not authorized. + env(token::createOffer(A1, nftID, USD(10)), + token::owner(A2), + ter(tecUNFUNDED_OFFER)); + env.close(); + + // Artificially create an unauthorized trustline with balance. Don't + // close ledger before running the actual tests against this trustline. + // After ledger is closed, the trustline will not exist. + auto const unauthTrustline = [&](OpenView& view, + beast::Journal) -> bool { + auto const sleA1 = + std::make_shared(keylet::line(A1, G1, G1["USD"].currency)); + sleA1->setFieldAmount(sfBalance, A1["USD"](-1000)); + view.rawInsert(sleA1); + return true; + }; + env.app().openLedger().modify(unauthTrustline); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: check that buyer can't make an offer even with balance + env(token::createOffer(A1, nftID, USD(10)), + token::owner(A2), + ter(tecNO_AUTH)); + } + else + { + // old behavior: can create an offer if balance allows, regardless + // ot authorization + env(token::createOffer(A1, nftID, USD(10)), token::owner(A2)); + } + } + + void + testAcceptBuyOffer_UnauthorizedBuyer(FeatureBitset features) + { + testcase("Seller tries to accept buy offer from unauth buyer"); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + auto const [nftID, _] = mintAndOfferNFT(env, A2, drops(1)); + + // First we authorize buyer and seller so that he can create buy offer + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(10))); + env(trust(A2, limit)); + env(trust(G1, limit, A2, tfSetfAuth)); + env(pay(G1, A2, USD(10))); + env.close(); + + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(10)), token::owner(A2)); + env.close(); + + env(pay(A1, G1, USD(10))); + env(trust(A1, USD(0))); + env(trust(G1, A1["USD"](0))); + env.close(); + + // Replace an existing authorized trustline with artificial unauthorized + // trustline with balance. Don't close ledger before running the actual + // tests against this trustline. After ledger is closed, the trustline + // will not exist. + auto const unauthTrustline = [&](OpenView& view, + beast::Journal) -> bool { + auto const sleA1 = + std::make_shared(keylet::line(A1, G1, G1["USD"].currency)); + sleA1->setFieldAmount(sfBalance, A1["USD"](-1000)); + view.rawInsert(sleA1); + return true; + }; + env.app().openLedger().modify(unauthTrustline); + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: check that offer can't be accepted even with balance + env(token::acceptBuyOffer(A2, buyIdx), ter(tecNO_AUTH)); + } + } + + void + testSellOffer_UnauthorizedSeller(FeatureBitset features) + { + testcase( + "Authorized buyer tries to accept sell offer from unauthorized " + "seller"); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + + auto const [nftID, _] = mintAndOfferNFT(env, A2, drops(1)); + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: can't create sell offer if there is no trustline but auth + // required + env(token::createOffer(A2, nftID, USD(10)), + txflags(tfSellNFToken), + ter(tecNO_LINE)); + + env(trust(A2, limit)); + // test: can't create sell offer if not authorized to hold token + env(token::createOffer(A2, nftID, USD(10)), + txflags(tfSellNFToken), + ter(tecNO_AUTH)); + + // Authorizing trustline to make an offer creation possible + env(trust(G1, USD(0), A2, tfSetfAuth)); + env.close(); + auto const sellIdx = keylet::nftoffer(A2, env.seq(A2)).key; + env(token::createOffer(A2, nftID, USD(10)), txflags(tfSellNFToken)); + env.close(); + // + + // Reseting trustline to delete it. This allows to check if + // already existing offers handled correctly + env(trust(A2, USD(0))); + env.close(); + + // test: G1 requires authorization of A1, no trust line exists + env(token::acceptSellOffer(A1, sellIdx), ter(tecNO_LINE)); + env.close(); + + // trust line created, but not authorized + env(trust(A2, limit)); + env.close(); + + // test: G1 requires authorization of A1 + env(token::acceptSellOffer(A1, sellIdx), ter(tecNO_AUTH)); + env.close(); + } + else + { + auto const sellIdx = keylet::nftoffer(A2, env.seq(A2)).key; + + // Old behavior: sell offer can be created without authorization + env(token::createOffer(A2, nftID, USD(10)), txflags(tfSellNFToken)); + env.close(); + + // Old behavior: it is possible to sell NFT and receive IOUs + // without the authorization + env(token::acceptSellOffer(A1, sellIdx)); + env.close(); + + BEAST_EXPECT(env.balance(A2, USD) == USD(10)); + } + } + + void + testSellOffer_UnauthorizedBuyer(FeatureBitset features) + { + testcase("Unauthorized buyer tries to accept sell offer"); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A2, limit)); + env(trust(G1, limit, A2, tfSetfAuth)); + + auto const [_, sellIdx] = mintAndOfferNFT(env, A2, USD(10)); + + // test: check that buyer can't accept an offer if they're not + // authorized. + env(token::acceptSellOffer(A1, sellIdx), ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Creating an artificial unauth trustline + auto const unauthTrustline = [&](OpenView& view, + beast::Journal) -> bool { + auto const sleA1 = + std::make_shared(keylet::line(A1, G1, G1["USD"].currency)); + sleA1->setFieldAmount(sfBalance, A1["USD"](-1000)); + view.rawInsert(sleA1); + return true; + }; + env.app().openLedger().modify(unauthTrustline); + if (features[fixEnforceNFTokenTrustlineV2]) + { + env(token::acceptSellOffer(A1, sellIdx), ter(tecNO_AUTH)); + } + } + + void + testBrokeredAcceptOffer_UnauthorizedBroker(FeatureBitset features) + { + testcase("Unauthorized broker bridges authorized buyer and seller."); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + Account broker{"broker"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2, broker); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + env(trust(A2, limit)); + env(trust(G1, limit, A2, tfSetfAuth)); + env(pay(G1, A2, USD(1000))); + env.close(); + + auto const [nftID, sellIdx] = mintAndOfferNFT(env, A2, USD(10)); + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(11)), token::owner(A2)); + env.close(); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: G1 requires authorization of broker, no trust line exists + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecNO_LINE)); + env.close(); + + // trust line created, but not authorized + env(trust(broker, limit)); + env.close(); + + // test: G1 requires authorization of broker + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecNO_AUTH)); + env.close(); + + // test: can still be brokered without broker fee. + env(token::brokerOffers(broker, buyIdx, sellIdx)); + env.close(); + } + else + { + // Old behavior: broker can receive IOUs without the authorization + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1))); + env.close(); + + BEAST_EXPECT(env.balance(broker, USD) == USD(1)); + } + } + + void + testBrokeredAcceptOffer_UnauthorizedBuyer(FeatureBitset features) + { + testcase( + "Authorized broker tries to bridge offers from unauthorized " + "buyer."); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + Account broker{"broker"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2, broker); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, USD(0), A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + env(trust(A2, limit)); + env(trust(G1, USD(0), A2, tfSetfAuth)); + env(pay(G1, A2, USD(1000))); + env(trust(broker, limit)); + env(trust(G1, USD(0), broker, tfSetfAuth)); + env(pay(G1, broker, USD(1000))); + env.close(); + + auto const [nftID, sellIdx] = mintAndOfferNFT(env, A2, USD(10)); + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(11)), token::owner(A2)); + env.close(); + + // Resetting buyer's trust line to delete it + env(pay(A1, G1, USD(1000))); + env(trust(A1, USD(0))); + env.close(); + + auto const unauthTrustline = [&](OpenView& view, + beast::Journal) -> bool { + auto const sleA1 = + std::make_shared(keylet::line(A1, G1, G1["USD"].currency)); + sleA1->setFieldAmount(sfBalance, A1["USD"](-1000)); + view.rawInsert(sleA1); + return true; + }; + env.app().openLedger().modify(unauthTrustline); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: G1 requires authorization of A2 + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecNO_AUTH)); + env.close(); + } + } + + void + testBrokeredAcceptOffer_UnauthorizedSeller(FeatureBitset features) + { + testcase( + "Authorized broker tries to bridge offers from unauthorized " + "seller."); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account A1{"A1"}; + Account A2{"A2"}; + Account broker{"broker"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, A1, A2, broker); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + env(trust(broker, limit)); + env(trust(G1, limit, broker, tfSetfAuth)); + env(pay(G1, broker, USD(1000))); + env.close(); + + // Authorizing trustline to make an offer creation possible + env(trust(G1, USD(0), A2, tfSetfAuth)); + env.close(); + + auto const [nftID, sellIdx] = mintAndOfferNFT(env, A2, USD(10)); + auto const buyIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(11)), token::owner(A2)); + env.close(); + + // Reseting trustline to delete it. This allows to check if + // already existing offers handled correctly + env(trust(A2, USD(0))); + env.close(); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: G1 requires authorization of broker, no trust line exists + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecNO_LINE)); + env.close(); + + // trust line created, but not authorized + env(trust(A2, limit)); + env.close(); + + // test: G1 requires authorization of A2 + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1)), + ter(tecNO_AUTH)); + env.close(); + + // test: cannot be brokered even without broker fee. + env(token::brokerOffers(broker, buyIdx, sellIdx), ter(tecNO_AUTH)); + env.close(); + } + else + { + // Old behavior: broker can receive IOUs without the authorization + env(token::brokerOffers(broker, buyIdx, sellIdx), + token::brokerFee(USD(1))); + env.close(); + + BEAST_EXPECT(env.balance(A2, USD) == USD(10)); + return; + } + } + + void + testTransferFee_UnauthorizedMinter(FeatureBitset features) + { + testcase("Unauthorized minter receives transfer fee."); + using namespace test::jtx; + + Env env(*this, features); + Account G1{"G1"}; + Account minter{"minter"}; + Account A1{"A1"}; + Account A2{"A2"}; + auto const USD{G1["USD"]}; + + env.fund(XRP(10000), G1, minter, A1, A2); + env(fset(G1, asfRequireAuth)); + env.close(); + + auto const limit = USD(10000); + + env(trust(A1, limit)); + env(trust(G1, limit, A1, tfSetfAuth)); + env(pay(G1, A1, USD(1000))); + env(trust(A2, limit)); + env(trust(G1, limit, A2, tfSetfAuth)); + env(pay(G1, A2, USD(1000))); + + env(trust(minter, limit)); + env.close(); + + // We authorized A1 and A2, but not the minter. + // Now mint NFT + auto const [nftID, minterSellIdx] = + mintAndOfferNFT(env, minter, drops(1), 1); + env(token::acceptSellOffer(A1, minterSellIdx)); + + uint256 const sellIdx = keylet::nftoffer(A1, env.seq(A1)).key; + env(token::createOffer(A1, nftID, USD(100)), txflags(tfSellNFToken)); + + if (features[fixEnforceNFTokenTrustlineV2]) + { + // test: G1 requires authorization + env(token::acceptSellOffer(A2, sellIdx), ter(tecNO_AUTH)); + env.close(); + } + else + { + // Old behavior: can sell for USD. Minter can receive tokens + env(token::acceptSellOffer(A2, sellIdx)); + env.close(); + + BEAST_EXPECT(env.balance(minter, USD) == USD(0.001)); + } + } + + void + run() override + { + using namespace test::jtx; + static FeatureBitset const all{supported_amendments()}; + + static std::array const features = { + all - fixEnforceNFTokenTrustlineV2, all}; + + for (auto const feature : features) + { + testBuyOffer_UnauthorizedSeller(feature); + testCreateBuyOffer_UnauthorizedBuyer(feature); + testAcceptBuyOffer_UnauthorizedBuyer(feature); + testSellOffer_UnauthorizedSeller(feature); + testSellOffer_UnauthorizedBuyer(feature); + testBrokeredAcceptOffer_UnauthorizedBroker(feature); + testBrokeredAcceptOffer_UnauthorizedBuyer(feature); + testBrokeredAcceptOffer_UnauthorizedSeller(feature); + testTransferFee_UnauthorizedMinter(feature); + } + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(NFTokenAuth, tx, ripple, 2); + +} // namespace ripple \ No newline at end of file diff --git a/src/xrpld/app/tx/detail/NFTokenAcceptOffer.cpp b/src/xrpld/app/tx/detail/NFTokenAcceptOffer.cpp index 4c5fdb7683..ab74e5ac39 100644 --- a/src/xrpld/app/tx/detail/NFTokenAcceptOffer.cpp +++ b/src/xrpld/app/tx/detail/NFTokenAcceptOffer.cpp @@ -160,6 +160,27 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) if ((*so)[sfAmount] > (*bo)[sfAmount] - *brokerFee) return tecINSUFFICIENT_PAYMENT; + + // Check if broker is allowed to receive the fee with these IOUs. + if (!brokerFee->native() && + ctx.view.rules().enabled(fixEnforceNFTokenTrustlineV2)) + { + auto res = nft::checkTrustlineAuthorized( + ctx.view, + ctx.tx[sfAccount], + ctx.j, + brokerFee->asset().get()); + if (res != tesSUCCESS) + return res; + + res = nft::checkTrustlineDeepFrozen( + ctx.view, + ctx.tx[sfAccount], + ctx.j, + brokerFee->asset().get()); + if (res != tesSUCCESS) + return res; + } } } @@ -208,6 +229,38 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) fhZERO_IF_FROZEN, ctx.j) < needed) return tecINSUFFICIENT_FUNDS; + + // Check that the account accepting the buy offer (he's selling the NFT) + // is allowed to receive IOUs. Also check that this offer's creator is + // authorized. But we need to exclude the case when the transaction is + // created by the broker. + if (ctx.view.rules().enabled(fixEnforceNFTokenTrustlineV2) && + !needed.native()) + { + auto res = nft::checkTrustlineAuthorized( + ctx.view, bo->at(sfOwner), ctx.j, needed.asset().get()); + if (res != tesSUCCESS) + return res; + + if (!so) + { + res = nft::checkTrustlineAuthorized( + ctx.view, + ctx.tx[sfAccount], + ctx.j, + needed.asset().get()); + if (res != tesSUCCESS) + return res; + + res = nft::checkTrustlineDeepFrozen( + ctx.view, + ctx.tx[sfAccount], + ctx.j, + needed.asset().get()); + if (res != tesSUCCESS) + return res; + } + } } if (so) @@ -270,42 +323,74 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx) } // Make sure that we are allowed to hold what the taker will pay us. - // This is a similar approach taken by usual offers. if (!needed.native()) { - auto const result = checkAcceptAsset( - ctx.view, - ctx.flags, - (*so)[sfOwner], - ctx.j, - needed.asset().get()); - if (result != tesSUCCESS) - return result; + if (ctx.view.rules().enabled(fixEnforceNFTokenTrustlineV2)) + { + auto res = nft::checkTrustlineAuthorized( + ctx.view, + (*so)[sfOwner], + ctx.j, + needed.asset().get()); + if (res != tesSUCCESS) + return res; + + if (!bo) + { + res = nft::checkTrustlineAuthorized( + ctx.view, + ctx.tx[sfAccount], + ctx.j, + needed.asset().get()); + if (res != tesSUCCESS) + return res; + } + } + + auto const res = nft::checkTrustlineDeepFrozen( + ctx.view, (*so)[sfOwner], ctx.j, needed.asset().get()); + if (res != tesSUCCESS) + return res; } } - // Fix a bug where the transfer of an NFToken with a transfer fee could - // give the NFToken issuer an undesired trust line. - if (ctx.view.rules().enabled(fixEnforceNFTokenTrustline)) + // Additional checks are required in case a minter set a transfer fee for + // this nftoken + auto const& offer = bo ? bo : so; + if (!offer) + // Purely defensive, should be caught in preflight. + return tecINTERNAL; + + auto const& tokenID = offer->at(sfNFTokenID); + auto const& amount = offer->at(sfAmount); + auto const nftMinter = nft::getIssuer(tokenID); + + if (nft::getTransferFee(tokenID) != 0 && !amount.native()) { - std::shared_ptr const& offer = bo ? bo : so; - if (!offer) - // Should be caught in preflight. - return tecINTERNAL; - - uint256 const& tokenID = offer->at(sfNFTokenID); - STAmount const& amount = offer->at(sfAmount); - if (nft::getTransferFee(tokenID) != 0 && + // Fix a bug where the transfer of an NFToken with a transfer fee could + // give the NFToken issuer an undesired trust line. + // Issuer doesn't need a trust line to accept their own currency. + if (ctx.view.rules().enabled(fixEnforceNFTokenTrustline) && (nft::getFlags(tokenID) & nft::flagCreateTrustLines) == 0 && - !amount.native()) + nftMinter != amount.getIssuer() && + !ctx.view.read(keylet::line(nftMinter, amount.issue()))) + return tecNO_LINE; + + // Check that the issuer is allowed to receive IOUs. + if (ctx.view.rules().enabled(fixEnforceNFTokenTrustlineV2)) { - auto const issuer = nft::getIssuer(tokenID); - // Issuer doesn't need a trust line to accept their own currency. - if (issuer != amount.getIssuer() && - !ctx.view.read(keylet::line(issuer, amount.issue()))) - return tecNO_LINE; + auto res = nft::checkTrustlineAuthorized( + ctx.view, nftMinter, ctx.j, amount.asset().get()); + if (res != tesSUCCESS) + return res; + + res = nft::checkTrustlineDeepFrozen( + ctx.view, nftMinter, ctx.j, amount.asset().get()); + if (res != tesSUCCESS) + return res; } } + return tesSUCCESS; } @@ -524,62 +609,4 @@ NFTokenAcceptOffer::doApply() return tecINTERNAL; } -TER -NFTokenAcceptOffer::checkAcceptAsset( - ReadView const& view, - ApplyFlags const flags, - AccountID const id, - beast::Journal const j, - Issue const& issue) -{ - // Only valid for custom currencies - - if (!view.rules().enabled(featureDeepFreeze)) - { - return tesSUCCESS; - } - - XRPL_ASSERT( - !isXRP(issue.currency), - "NFTokenAcceptOffer::checkAcceptAsset : valid to check."); - auto const issuerAccount = view.read(keylet::account(issue.account)); - - if (!issuerAccount) - { - JLOG(j.debug()) - << "delay: can't receive IOUs from non-existent issuer: " - << to_string(issue.account); - - return tecNO_ISSUER; - } - - // An account can not create a trustline to itself, so no line can exist - // to be frozen. Additionally, an issuer can always accept its own - // issuance. - if (issue.account == id) - { - return tesSUCCESS; - } - - auto const trustLine = - view.read(keylet::line(id, issue.account, issue.currency)); - - if (!trustLine) - { - return tesSUCCESS; - } - - // There's no difference which side enacted deep freeze, accepting - // tokens shouldn't be possible. - bool const deepFrozen = - (*trustLine)[sfFlags] & (lsfLowDeepFreeze | lsfHighDeepFreeze); - - if (deepFrozen) - { - return tecFROZEN; - } - - return tesSUCCESS; -} - } // namespace ripple diff --git a/src/xrpld/app/tx/detail/NFTokenAcceptOffer.h b/src/xrpld/app/tx/detail/NFTokenAcceptOffer.h index 6a594e2b2c..dff3febbb2 100644 --- a/src/xrpld/app/tx/detail/NFTokenAcceptOffer.h +++ b/src/xrpld/app/tx/detail/NFTokenAcceptOffer.h @@ -44,14 +44,6 @@ private: AccountID const& seller, uint256 const& nfTokenID); - static TER - checkAcceptAsset( - ReadView const& view, - ApplyFlags const flags, - AccountID const id, - beast::Journal const j, - Issue const& issue); - public: static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; diff --git a/src/xrpld/app/tx/detail/NFTokenUtils.cpp b/src/xrpld/app/tx/detail/NFTokenUtils.cpp index 9c9754aa95..4866a3b385 100644 --- a/src/xrpld/app/tx/detail/NFTokenUtils.cpp +++ b/src/xrpld/app/tx/detail/NFTokenUtils.cpp @@ -1004,6 +1004,18 @@ tokenOfferCreatePreclaim( } } + if (view.rules().enabled(fixEnforceNFTokenTrustlineV2) && !amount.native()) + { + // If this is a sell offer, check that the account is allowed to + // receive IOUs. If this is a buy offer, we have to check that trustline + // is authorized, even though we previosly checked it's balance via + // accountHolds. This is due to a possibility of existence of + // unauthorized trustlines with balance + auto const res = nft::checkTrustlineAuthorized( + view, acctID, j, amount.asset().get()); + if (res != tesSUCCESS) + return res; + } return tesSUCCESS; } @@ -1081,5 +1093,115 @@ tokenOfferCreateApply( return tesSUCCESS; } +TER +checkTrustlineAuthorized( + ReadView const& view, + AccountID const id, + beast::Journal const j, + Issue const& issue) +{ + // Only valid for custom currencies + XRPL_ASSERT( + !isXRP(issue.currency), + "ripple::nft::checkTrustlineAuthorized : valid to check."); + + if (view.rules().enabled(fixEnforceNFTokenTrustlineV2)) + { + auto const issuerAccount = view.read(keylet::account(issue.account)); + if (!issuerAccount) + { + JLOG(j.debug()) << "ripple::nft::checkTrustlineAuthorized: can't " + "receive IOUs from non-existent issuer: " + << to_string(issue.account); + + return tecNO_ISSUER; + } + + // An account can not create a trustline to itself, so no line can + // exist to be authorized. Additionally, an issuer can always accept + // its own issuance. + if (issue.account == id) + { + return tesSUCCESS; + } + + if (issuerAccount->isFlag(lsfRequireAuth)) + { + auto const trustLine = + view.read(keylet::line(id, issue.account, issue.currency)); + + if (!trustLine) + { + return tecNO_LINE; + } + + // Entries have a canonical representation, determined by a + // lexicographical "greater than" comparison employing strict + // weak ordering. Determine which entry we need to access. + if (!trustLine->isFlag( + id > issue.account ? lsfLowAuth : lsfHighAuth)) + { + return tecNO_AUTH; + } + } + } + + return tesSUCCESS; +} + +TER +checkTrustlineDeepFrozen( + ReadView const& view, + AccountID const id, + beast::Journal const j, + Issue const& issue) +{ + // Only valid for custom currencies + XRPL_ASSERT( + !isXRP(issue.currency), + "ripple::nft::checkTrustlineDeepFrozen : valid to check."); + + if (view.rules().enabled(featureDeepFreeze)) + { + auto const issuerAccount = view.read(keylet::account(issue.account)); + if (!issuerAccount) + { + JLOG(j.debug()) << "ripple::nft::checkTrustlineDeepFrozen: can't " + "receive IOUs from non-existent issuer: " + << to_string(issue.account); + + return tecNO_ISSUER; + } + + // An account can not create a trustline to itself, so no line can + // exist to be frozen. Additionally, an issuer can always accept its + // own issuance. + if (issue.account == id) + { + return tesSUCCESS; + } + + auto const trustLine = + view.read(keylet::line(id, issue.account, issue.currency)); + + if (!trustLine) + { + return tesSUCCESS; + } + + // There's no difference which side enacted deep freeze, accepting + // tokens shouldn't be possible. + bool const deepFrozen = + (*trustLine)[sfFlags] & (lsfLowDeepFreeze | lsfHighDeepFreeze); + + if (deepFrozen) + { + return tecFROZEN; + } + } + + return tesSUCCESS; +} + } // namespace nft } // namespace ripple diff --git a/src/xrpld/app/tx/detail/NFTokenUtils.h b/src/xrpld/app/tx/detail/NFTokenUtils.h index 38ced59e9c..7ee0541984 100644 --- a/src/xrpld/app/tx/detail/NFTokenUtils.h +++ b/src/xrpld/app/tx/detail/NFTokenUtils.h @@ -152,6 +152,20 @@ tokenOfferCreateApply( beast::Journal j, std::uint32_t txFlags = lsfSellNFToken); +TER +checkTrustlineAuthorized( + ReadView const& view, + AccountID const id, + beast::Journal const j, + Issue const& issue); + +TER +checkTrustlineDeepFrozen( + ReadView const& view, + AccountID const id, + beast::Journal const j, + Issue const& issue); + } // namespace nft } // namespace ripple