From c42ec53a84a0dfb5bde073b05e4c197ba0bf7903 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Wed, 27 May 2026 10:11:22 -0400 Subject: [PATCH] Fix book_offers rpc reports inflated mpt liquidity --- include/xrpl/ledger/helpers/TokenHelpers.h | 7 + src/libxrpl/ledger/helpers/TokenHelpers.cpp | 10 +- src/test/app/OfferMPT_test.cpp | 215 ++++++++++++++++++++ src/test/jtx/impl/mpt.cpp | 1 + src/xrpld/app/misc/NetworkOPs.cpp | 49 +++-- 5 files changed, 265 insertions(+), 17 deletions(-) diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index f736e51d28..78d4c62fd7 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -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 diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 7191456868..8a3f6d0c4e 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -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 diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index e9366f7c32..0496d69fef 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -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); } diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 1e127e7c05..567c21a6a7 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 12c79b821c..834824c763 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -76,6 +76,7 @@ #include #include #include +#include #include #include #include @@ -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(