diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 113155e530..e7220aae68 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -652,7 +652,15 @@ limitStepIn( // under an amendment. ofrAmt = offer.limitIn(ofrAmt, inLmt, /* roundUp */ false); stpAmt.out = ofrAmt.out; - ownerGives = mulRatio(ofrAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + // Round up for MPT output so the offer owner pays the full + // ceil(amount × rate) fee, matching direct Payment semantics. IOU uses + // floating-point arithmetic so the floor/ceil distinction is sub-epsilon + // there; preserve the historical false to avoid changing IOU behavior. + ownerGives = mulRatio( + ofrAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); } } @@ -671,7 +679,11 @@ limitStepOut( if (limit < stpAmt.out) { stpAmt.out = limit; - ownerGives = mulRatio(stpAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + ownerGives = mulRatio( + stpAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); ofrAmt = offer.limitOut( ofrAmt, stpAmt.out, @@ -770,7 +782,11 @@ BookStep::forEachOffer( TAmounts stpAmt{mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), ofrAmt.out}; // owner pays the transfer fee. - auto ownerGives = mulRatio(ofrAmt.out, ofrOutRate, QUALITY_ONE, /*roundUp*/ false); + auto ownerGives = mulRatio( + ofrAmt.out, + ofrOutRate, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); auto const funds = offer.isFunded() ? ownerGives // Offer owner is issuer; they have unlimited funds diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index 8c07a43983..11c73b0c83 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -2725,6 +2725,50 @@ public: using namespace jtx; auto const gw1 = Account("gateway1"); + { + auto const issuer = Account("issuer"); + auto const sender = Account("sender"); + auto const receiver = Account("receiver"); + auto const seller = Account("seller"); + auto const buyer = Account("buyer"); + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, sender, receiver, seller, buyer); + env.close(); + + MPTTester mpt{ + {.env = env, + .issuer = issuer, + .holders = {sender, receiver, seller, buyer}, + .transferFee = 100}}; + MPT const token = mpt; + + mpt.pay(issuer, sender, 2'000); + mpt.pay(issuer, seller, 2'000); + + // A direct holder-to-holder payment of 999 MPT at a 0.1% fee + // requires 1000 from the sender and burns one MPT. + env(pay(sender, receiver, token(999)), Ter(tecPATH_PARTIAL)); + env.close(); + env(pay(sender, receiver, token(999)), Sendmax(token(1'000))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(sender) == 1'000); + BEAST_EXPECT(mpt.getBalance(receiver) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'999); + + // CLOB crossing should apply the same fee quantum. The offer + // owner pays ceil(999 * 1.001) = 1000, not floor(...) = 999. + env(offer(seller, XRP(999), token(999))); + env.close(); + env(offer(buyer, token(999), XRP(999))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(seller) == 1'000); + BEAST_EXPECT(mpt.getBalance(buyer) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'998); + } + auto test = [&](auto&& issue1, auto&& issue2) { Env env{*this, features};