Fix book_offers rpc reports inflated mpt liquidity

This commit is contained in:
Gregory Tsipenyuk
2026-05-27 10:11:22 -04:00
parent 1162371def
commit c42ec53a84
5 changed files with 265 additions and 17 deletions

View File

@@ -212,6 +212,13 @@ accountFunds(
AuthHandling authHandling,
beast::Journal j);
/** Returns the transfer fee as Rate based on the type of token
* @param view The ledger view
* @param asset The asset being transferred
*/
[[nodiscard]] Rate
transferRate(ReadView const& view, Asset const& asset);
/** Returns the transfer fee as Rate based on the type of token
* @param view The ledger view
* @param amount The amount to transfer

View File

@@ -433,13 +433,19 @@ accountFunds(
}
Rate
transferRate(ReadView const& view, STAmount const& amount)
transferRate(ReadView const& view, Asset const& asset)
{
return amount.asset().visit(
return asset.visit(
[&](Issue const& issue) { return transferRate(view, issue.getIssuer()); },
[&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); });
}
Rate
transferRate(ReadView const& view, STAmount const& amount)
{
return transferRate(view, amount.asset());
}
//------------------------------------------------------------------------------
//
// Holding operations

View File

@@ -4888,6 +4888,220 @@ public:
}
}
void
testBookOffersMPTFunding(FeatureBitset features)
{
testcase("book_offers uses MPT issuer capacity, transfer fees, and locks");
using namespace jtx;
Account const issuer{"issuer"};
Account const maker{"maker"};
Account const buyer{"buyer"};
// Issuer-owned MPT offers are funded only by remaining issuance
// capacity. Once ordinary issuance consumes the cap, book_offers must
// report the stale issuer offer as zero-funded.
{
Env env{*this, features};
env.fund(XRP(10'000), issuer, maker, buyer);
env.close();
MPTTester musd(
{.env = env, .issuer = issuer, .holders = {maker, buyer}, .maxAmt = 100});
MPT const usd = musd;
auto const issuerOfferSeq = env.seq(issuer);
env(offer(issuer, XRP(100), usd(100)));
musd.pay(issuer, maker, 100);
auto const issuance = env.le(keylet::mptIssuance(usd.mpt()));
if (!BEAST_EXPECT(issuance))
return;
BEAST_EXPECT(issuance->getFieldU64(sfOutstandingAmount) == 100);
BEAST_EXPECT(issuance->getFieldU64(sfMaximumAmount) == 100);
env(offer(maker, XRP(200), usd(100)));
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))
return;
json::Value const& issuerOffer = bookOffers[0u];
BEAST_EXPECT(issuerOffer[sfAccount.jsonName] == issuer.human());
BEAST_EXPECT(issuerOffer[sfSequence.jsonName] == issuerOfferSeq);
BEAST_EXPECT(issuerOffer[jss::owner_funds] == "0");
BEAST_EXPECT(issuerOffer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(issuerOffer[jss::taker_gets_funded][jss::value] == "0");
BEAST_EXPECT(issuerOffer.isMember(jss::taker_pays_funded));
BEAST_EXPECT(issuerOffer[jss::taker_pays_funded] == "0");
}
// Multiple issuer-owned MPT offers share the same bounded self-issue
// capacity. The second offer exercises the cached running balance path
// after the first offer has consumed part of the issuer's capacity.
{
Env env{*this, features};
env.fund(XRP(10'000), issuer, buyer);
env.close();
MPTTester const musd({.env = env, .issuer = issuer, .holders = {buyer}, .maxAmt = 150});
MPT const usd = musd;
auto const firstIssuerOfferSeq = env.seq(issuer);
env(offer(issuer, XRP(100), usd(100)));
auto const secondIssuerOfferSeq = env.seq(issuer);
env(offer(issuer, XRP(100), usd(100)));
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))
return;
json::Value const& firstOffer = bookOffers[0u];
BEAST_EXPECT(firstOffer[sfAccount.jsonName] == issuer.human());
BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstIssuerOfferSeq);
BEAST_EXPECT(firstOffer[jss::owner_funds] == "150");
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[sfAccount.jsonName] == issuer.human());
BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondIssuerOfferSeq);
BEAST_EXPECT(!secondOffer.isMember(jss::owner_funds));
BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "50");
BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "50000000");
}
auto checkTransferFeeBookOffers = [&](std::uint16_t transferFee, auto&& checkOffers) {
Env env{*this, features};
env.fund(XRP(10'000), issuer, maker, buyer);
env.close();
MPTTester const musd(
{.env = env,
.issuer = issuer,
.holders = {maker, buyer},
.transferFee = transferFee,
.pay = 3'000});
MPT const usd = musd;
if (transferFee != 0)
BEAST_EXPECT(musd.checkTransferFee(transferFee));
auto const firstOfferSeq = env.seq(maker);
env(offer(maker, XRP(1'500), usd(1'500)));
auto const secondOfferSeq = env.seq(maker);
env(offer(maker, XRP(1'500), usd(1'500)));
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))
return;
checkOffers(bookOffers, firstOfferSeq, secondOfferSeq);
};
// With no MPT transfer fee, two identical maker offers backed by 3000
// owner funds are both fully funded for 1500 MPT.
checkTransferFeeBookOffers(
0,
[&](json::Value const& bookOffers,
std::uint32_t firstOfferSeq,
std::uint32_t secondOfferSeq) {
for (auto const i : {0u, 1u})
{
json::Value const& offer = bookOffers[i];
BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
BEAST_EXPECT(
offer[sfSequence.jsonName] == (i == 0u ? firstOfferSeq : secondOfferSeq));
BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
}
BEAST_EXPECT(bookOffers[0u][jss::owner_funds] == "3000");
});
// With a 50% MPT transfer fee, the first identical maker offer consumes
// 2250 owner funds, so the second offer can deliver only 500 MPT.
checkTransferFeeBookOffers(
50'000,
[&](json::Value const& bookOffers,
std::uint32_t firstOfferSeq,
std::uint32_t secondOfferSeq) {
json::Value const& firstOffer = bookOffers[0u];
BEAST_EXPECT(firstOffer[sfAccount.jsonName] == maker.human());
BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstOfferSeq);
BEAST_EXPECT(firstOffer[jss::owner_funds] == "3000");
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[sfAccount.jsonName] == maker.human());
BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondOfferSeq);
// A 50% MPT transfer fee leaves only 750 owner funds after
// the first offer. That can fund 500 MPT delivered to the
// taker on the same second offer that was fully funded without
// the transfer fee.
BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "500");
BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "500000000");
});
// An MPT global lock removes the locked MPT book liquidity from the
// public snapshot instead of reporting it as funded.
{
Env env{*this, features};
env.fund(XRP(10'000), issuer, maker, buyer);
env.close();
MPTTester musd(
{.env = env,
.issuer = issuer,
.holders = {maker, buyer},
.pay = 100,
.flags = kMptDexFlags | tfMPTCanLock});
MPT const usd = musd;
auto const offerSeq = env.seq(maker);
env(offer(maker, XRP(100), usd(100)));
{
json::Value const jrr = getBookOffers(env, XRP, usd);
json::Value const& bookOffers = jrr[jss::offers];
BEAST_EXPECT(bookOffers.isArray());
if (!BEAST_EXPECT(bookOffers.size() == 1))
return;
json::Value const& offer = bookOffers[0u];
BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq);
BEAST_EXPECT(offer[jss::owner_funds] == "100");
BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
}
musd.set({.flags = tfMPTLock});
{
json::Value const jrr = getBookOffers(env, XRP, usd);
json::Value const& bookOffers = jrr[jss::offers];
BEAST_EXPECT(bookOffers.isArray());
BEAST_EXPECT(bookOffers.size() == 0);
}
}
}
void
testAll(FeatureBitset features)
{
@@ -4944,6 +5158,7 @@ public:
testRmSmallIncreasedQOffersMPT(features);
testFillOrKill(features);
testTickSize(features);
testBookOffersMPTFunding(features);
testAutoCreateReserve(features);
}

View File

@@ -15,6 +15,7 @@
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>

View File

@@ -76,6 +76,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ApiVersion.h>
@@ -4310,8 +4311,7 @@ NetworkOPsImp::getBookPage(
ReadView const& view = *lpLedger;
bool const bGlobalFreeze =
isGlobalFrozen(view, book.out.getIssuer()) || isGlobalFrozen(view, book.in.getIssuer());
bool const bGlobalFreeze = isGlobalFrozen(view, book.out) || isGlobalFrozen(view, book.in);
bool bDone = false;
bool bDirectAdvance = true;
@@ -4321,7 +4321,7 @@ NetworkOPsImp::getBookPage(
unsigned int uBookEntry = 0;
STAmount saDirRate;
auto const rate = transferRate(view, book.out.getIssuer());
auto const rate = transferRate(view, book.out);
auto viewJ = registry_.get().getJournal("View");
while (!bDone && iLimit-- > 0)
@@ -4370,12 +4370,37 @@ NetworkOPsImp::getBookPage(
auto const& saTakerPays = sleOffer->getFieldAmount(sfTakerPays);
STAmount saOwnerFunds;
bool firstOwnerOffer(true);
auto foundBalance = [&]() {
auto umBalanceEntry = umBalance.find(uOfferOwnerID);
if (umBalanceEntry == umBalance.end())
return false;
// Found in running balance table.
saOwnerFunds = umBalanceEntry->second;
firstOwnerOffer = false;
return true;
};
if (book.out.getIssuer() == uOfferOwnerID)
{
// If an offer is selling issuer's own IOUs, it is fully
// funded.
saOwnerFunds = saTakerGets;
book.out.visit(
[&](Issue const&) {
// If an offer is selling issuer's own IOUs, it is
// fully funded.
saOwnerFunds = saTakerGets;
},
[&](MPTIssue const& issue) {
// MPT issuers have bounded self-issuance. Use the
// running balance table so multiple issuer-owned
// offers share the same remaining issuance
// headroom.
if (!foundBalance())
{
// Did not find balance in table.
saOwnerFunds = issuerFundsToSelfIssue(view, issue);
}
});
}
else if (bGlobalFreeze)
{
@@ -4385,15 +4410,7 @@ NetworkOPsImp::getBookPage(
}
else
{
auto umBalanceEntry = umBalance.find(uOfferOwnerID);
if (umBalanceEntry != umBalance.end())
{
// Found in running balance table.
saOwnerFunds = umBalanceEntry->second;
firstOwnerOffer = false;
}
else
if (!foundBalance())
{
// Did not find balance in table.
@@ -4486,6 +4503,8 @@ NetworkOPsImp::getBookPage(
// This is the new code that uses the book iterators
// It has temporarily been disabled
// If this path is re-enabled, add MPT support mirroring the functional
// getBookPage() implementation above.
void
NetworkOPsImp::getBookPage(