From c6918f5915dd466489e5be2cb420af63ae02e954 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Sat, 16 May 2026 14:46:01 -0400 Subject: [PATCH] Prevent zero-input MPT offer fills after clipping --- src/libxrpl/tx/paths/OfferStream.cpp | 18 ++--- src/test/app/OfferMPT_test.cpp | 110 +++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp index 53a4223b43..328a623584 100644 --- a/src/libxrpl/tx/paths/OfferStream.cpp +++ b/src/libxrpl/tx/paths/OfferStream.cpp @@ -139,17 +139,17 @@ template TOfferStreamBase::shouldRmSmallIncreasedQOffer() const { // Consider removing the offer if: - // o `TakerPays` is XRP (because of XRP drops granularity) or + // o `TakerPays` is integral (because XRP/MPT have indivisible units) or // o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets` - constexpr bool const kIN_IS_XRP = std::is_same_v; - constexpr bool const kOUT_IS_XRP = std::is_same_v; + constexpr bool const kIN_IS_INTEGRAL = !std::is_same_v; + constexpr bool const kOUT_IS_INTEGRAL = !std::is_same_v; - if constexpr (kOUT_IS_XRP) + if constexpr (!kIN_IS_INTEGRAL && kOUT_IS_INTEGRAL) { - // If `TakerGets` is XRP, the worst this offer's quality can change is - // to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be - // remarkably good quality for any realistic asset, so these offers - // don't need this extra check. + // If only `TakerGets` is integral, the worst this offer's quality can + // change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This + // will be perfect quality for any realistic asset, so these + // offers don't need this extra check. return false; } @@ -159,7 +159,7 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const TAmounts const ofrAmts{ toAmount(offer_.amount().in), toAmount(offer_.amount().out)}; - if constexpr (!kIN_IS_XRP && !kOUT_IS_XRP) + if constexpr (!kIN_IS_INTEGRAL && !kOUT_IS_INTEGRAL) { if (Number(ofrAmts.in) >= Number(ofrAmts.out)) return false; diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index 4e7cdfdf34..15decf5f1b 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -608,6 +608,115 @@ public: testHelper2TokensMix(test); } + void + testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features) + { + using namespace jtx; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + + { + testcase("Partially funded MPT/XRP input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const gw = Account{"gw"}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}}); + + env(offer(alice, usd(1), drops(1'000'000))); + env.close(); + + auto const targetBalance = reserve(env, 2) + drops(999'999); + auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() - + env.current()->fees().base; + env(pay(alice, gw, drops(drain))); + env.close(); + + auto const aliceXRPBefore = env.balance(alice); + auto const bobXRPBefore = env.balance(bob); + + env(pay(gw, bob, drops(1'000'000)), + Sendmax(usd(1)), + Path(~XRP), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + BEAST_EXPECT(env.balance(alice) == aliceXRPBefore); + BEAST_EXPECT(env.balance(bob) == bobXRPBefore); + } + + { + testcase("Partially funded MPT/IOU input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const mptIssuer = Account{"mptIssuer"}; + auto const iouIssuer = Account{"iouIssuer"}; + + env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob); + env.close(); + + auto const eur = iouIssuer["EUR"]; + env.trust(eur(100), alice, bob); + env(pay(iouIssuer, alice, eur(0.5))); + env.close(); + + MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}}); + + env(offer(alice, usd(1), eur(1))); + env.close(); + + auto const aliceEURBefore = env.balance(alice, eur); + auto const bobEURBefore = env.balance(bob, eur); + + env(pay(mptIssuer, bob, eur(1)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore); + BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore); + } + + { + testcase("Partially funded MPT/MPT input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const issuerA = Account{"issuerA"}; + auto const issuerB = Account{"issuerB"}; + + env.fund(XRP(10'000), issuerA, issuerB, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}}); + MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}}); + + env(pay(issuerB, alice, eur(999'999))); + env.close(); + + env(offer(alice, usd(1), eur(1'000'000))); + env.close(); + + auto const aliceEURBefore = eur.getBalance(alice); + auto const bobEURBefore = eur.getBalance(bob); + + env(pay(issuerA, bob, eur(1'000'000)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore)); + BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore)); + } + } + void testInsufficientReserve(FeatureBitset features) { @@ -4958,6 +5067,7 @@ public: testTicketCancelOffer(features); testRmSmallIncreasedQOffersXRP(features); testRmSmallIncreasedQOffersMPT(features); + testPartiallyFundedMPTInputOfferZeroInput(features); testFillOrKill(features); testTickSize(features); }