mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-03 16:56:48 +00:00
Fix book_offers rpc reports inflated mpt liquidity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user