fix: Round up MPT owner cost in book_offers running balance

getBookPage charged an owner's later offers with the fee-grossed cost of earlier ones rounded to nearest, while BookStep rounds the MPT owner's cost up. With a fractional fee the RPC kept one unit too many per offer and could report a later offer as funded when execution delivers nothing. Round up as BookStep does.
This commit is contained in:
Gregory Tsipenyuk
2026-09-10 17:16:49 +01:00
parent 9403736199
commit 44da1a90c1
2 changed files with 70 additions and 1 deletions

View File

@@ -5982,6 +5982,57 @@ public:
BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "500000000");
});
// The running balance charged for an earlier offer must round the
// owner's fee-grossed cost up, as BookStep does. With the smallest fee
// (rate 1.00001), a maker holding 3 MPT with two 1-MPT offers pays 2
// for the first, leaving 1: not enough for the second. Rounding to
// nearest charged 1 and reported the second offer as fully funded.
{
Env env{*this, features};
env.fund(XRP(10'000), issuer, maker, buyer);
env.close();
MPT const usd = MPTTester(
{.env = env,
.issuer = issuer,
.holders = {maker, buyer},
.transferFee = 1,
.pay = 3});
auto const firstOfferSeq = env.seq(maker);
env(offer(maker, XRP(1), usd(1)));
auto const secondOfferSeq = env.seq(maker);
env(offer(maker, XRP(1), usd(1)));
env.close();
json::Value const jrr = getBookOffers(env, XRP, usd);
json::Value const& bookOffers = jrr[jss::offers];
BEAST_EXPECT(bookOffers.isArray());
if (BEAST_EXPECT(bookOffers.size() == 2))
{
json::Value const& firstOffer = bookOffers[0u];
BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstOfferSeq);
BEAST_EXPECT(firstOffer[jss::owner_funds] == "3");
BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded));
json::Value const& secondOffer = bookOffers[1u];
BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondOfferSeq);
BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "0");
BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "0");
}
// Execution on the same ledger state agrees: the buyer gets 1 MPT
// from the first offer, the maker pays 2, and the second offer
// delivers nothing. (.pay funded the buyer with 3 as well.)
env(offer(buyer, usd(2), XRP(2)));
env.close();
BEAST_EXPECT(env.balance(buyer, usd) == usd(3 + 1));
BEAST_EXPECT(env.balance(maker, usd) == usd(3 - 2));
}
// A large MPT balance used to overflow the fee adjustment. divide()
// assumes an IOU mantissa, always normalized into [1e15, 1e16), and
// scales the numerator by 1e17. An MPT mantissa is the raw int64

View File

@@ -4978,9 +4978,27 @@ NetworkOPsImp::getBookPage(
.setJson(jvOffer[jss::taker_pays_funded]);
}
// What the owner pays for this offer, charged against the
// running balance for the owner's later offers. BookStep
// rounds the MPT owner's fee-grossed cost up; multiply() rounds
// to nearest and would leave one unit per offer over-reported
// to the next offer. Can't overflow: saTakerGetsFunded <=
// floor(saOwnerFunds / offerRate).
auto const grossed = [&]() {
return saOwnerFunds.asset().visit(
[&](MPTIssue const&) {
auto const mpt = mulRatio(
saTakerGetsFunded.mpt(),
offerRate.value,
kParityRate.value,
/*roundUp*/ true);
return toSTAmount(mpt, saOwnerFunds.asset());
},
[&](Issue const&) { return multiply(saTakerGetsFunded, offerRate); });
};
STAmount const saOwnerPays = (kParityRate == offerRate)
? saTakerGetsFunded
: std::min(saOwnerFunds, multiply(saTakerGetsFunded, offerRate));
: std::min(saOwnerFunds, grossed());
umBalance[uOfferOwnerID] = saOwnerFunds - saOwnerPays;