fixReducedOffersV1: prevent offers from blocking order books: (#4512)

Curtail the occurrence of order books that are blocked by reduced offers
with the implementation of the fixReducedOffersV1 amendment.

This commit identifies three ways in which offers can be reduced:

1. A new offer can be partially crossed by existing offers, so the new
   offer is reduced when placed in the ledger.

2. An in-ledger offer can be partially crossed by a new offer in a
   transaction. So the in-ledger offer is reduced by the new offer.

3. An in-ledger offer may be under-funded. In this case the in-ledger
   offer is scaled down to match the available funds.

Reduced offers can block order books if the effective quality of the
reduced offer is worse than the quality of the original offer (from the
perspective of the taker). It turns out that, for small values, the
quality of the reduced offer can be significantly affected by the
rounding mode used during scaling computations.

This commit adjusts some rounding modes so that the quality of a reduced
offer is always at least as good (from the taker's perspective) as the
original offer.

The amendment is titled fixReducedOffersV1 because additional ways of
producing reduced offers may come to light. Therefore, there may be a
future need for a V2 amendment.
This commit is contained in:
Scott Schurr
2023-06-23 08:20:25 +03:00
committed by GitHub
parent 71d7d67fa3
commit 724a301599
12 changed files with 938 additions and 28 deletions

View File

@@ -0,0 +1,622 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2022 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/protocol/Feature.h>
#include <ripple/protocol/Quality.h>
#include <ripple/protocol/jss.h>
#include <test/jtx.h>
namespace ripple {
namespace test {
class ReducedOffer_test : public beast::unit_test::suite
{
static auto
ledgerEntryOffer(
jtx::Env& env,
jtx::Account const& acct,
std::uint32_t offer_seq)
{
Json::Value jvParams;
jvParams[jss::offer][jss::account] = acct.human();
jvParams[jss::offer][jss::seq] = offer_seq;
return env.rpc(
"json", "ledger_entry", to_string(jvParams))[jss::result];
}
static bool
offerInLedger(
jtx::Env& env,
jtx::Account const& acct,
std::uint32_t offerSeq)
{
Json::Value ledgerOffer = ledgerEntryOffer(env, acct, offerSeq);
return !(
ledgerOffer.isMember(jss::error) &&
ledgerOffer[jss::error].asString() == "entryNotFound");
}
// Common code to clean up unneeded offers.
static void
cleanupOldOffers(
jtx::Env& env,
jtx::Account const& acct1,
jtx::Account const& acct2,
std::uint32_t acct1OfferSeq,
std::uint32_t acct2OfferSeq)
{
env(offer_cancel(acct1, acct1OfferSeq));
env(offer_cancel(acct2, acct2OfferSeq));
env.close();
}
public:
void
testPartialCrossNewXrpIouQChange()
{
testcase("exercise partial cross new XRP/IOU offer Q change");
using namespace jtx;
auto const gw = Account{"gateway"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
// Make one test run without fixReducedOffersV1 and one with.
for (FeatureBitset features :
{supported_amendments() - fixReducedOffersV1,
supported_amendments() | fixReducedOffersV1})
{
Env env{*this, features};
// Make sure none of the offers we generate are under funded.
env.fund(XRP(10'000'000), gw, alice, bob);
env.close();
env(trust(alice, USD(10'000'000)));
env(trust(bob, USD(10'000'000)));
env.close();
env(pay(gw, bob, USD(10'000'000)));
env.close();
// Lambda that:
// 1. Exercises one offer pair,
// 2. Collects the results, and
// 3. Cleans up for the next offer pair.
// Returns 1 if the crossed offer has a bad rate for the book.
auto exerciseOfferPair =
[this, &env, &alice, &bob](
Amounts const& inLedger,
Amounts const& newOffer) -> unsigned int {
// Put inLedger offer in the ledger so newOffer can cross it.
std::uint32_t const aliceOfferSeq = env.seq(alice);
env(offer(alice, inLedger.in, inLedger.out));
env.close();
// Now alice's offer will partially cross bob's offer.
STAmount const initialRate = Quality(newOffer).rate();
std::uint32_t const bobOfferSeq = env.seq(bob);
STAmount const bobInitialBalance = env.balance(bob);
STAmount const bobsFee = drops(10);
env(offer(bob, newOffer.in, newOffer.out, tfSell),
fee(bobsFee));
env.close();
STAmount const bobFinalBalance = env.balance(bob);
// alice's offer should be fully crossed and so gone from
// the ledger.
if (!BEAST_EXPECT(!offerInLedger(env, alice, aliceOfferSeq)))
// If the in-ledger offer was not consumed then further
// results are meaningless.
return 1;
// bob's offer should be in the ledger, but reduced in size.
unsigned int badRate = 1;
{
Json::Value bobOffer =
ledgerEntryOffer(env, bob, bobOfferSeq);
STAmount const reducedTakerGets = amountFromJson(
sfTakerGets, bobOffer[jss::node][sfTakerGets.jsonName]);
STAmount const reducedTakerPays = amountFromJson(
sfTakerPays, bobOffer[jss::node][sfTakerPays.jsonName]);
STAmount const bobGot =
env.balance(bob) + bobsFee - bobInitialBalance;
BEAST_EXPECT(reducedTakerPays < newOffer.in);
BEAST_EXPECT(reducedTakerGets < newOffer.out);
STAmount const inLedgerRate =
Quality(Amounts{reducedTakerPays, reducedTakerGets})
.rate();
badRate = inLedgerRate > initialRate ? 1 : 0;
// If the inLedgerRate is less than initial rate, then
// incrementing the mantissa of the reduced taker pays
// should result in a rate higher than initial. Check
// this to verify that the largest allowable TakerPays
// was computed.
if (badRate == 0)
{
STAmount const tweakedTakerPays =
reducedTakerPays + drops(1);
STAmount const tweakedRate =
Quality(Amounts{tweakedTakerPays, reducedTakerGets})
.rate();
BEAST_EXPECT(tweakedRate > initialRate);
}
#if 0
std::cout << "Placed rate: " << initialRate
<< "; in-ledger rate: " << inLedgerRate
<< "; TakerPays: " << reducedTakerPays
<< "; TakerGets: " << reducedTakerGets
<< "; bob already got: " << bobGot << std::endl;
// #else
std::string_view filler =
inLedgerRate > initialRate ? "**" : " ";
std::cout << "| `" << reducedTakerGets << "` | `"
<< reducedTakerPays << "` | `" << initialRate
<< "` | " << filler << "`" << inLedgerRate << "`"
<< filler << " |`" << std::endl;
#endif
}
// In preparation for the next iteration make sure the two
// offers are gone from the ledger.
cleanupOldOffers(env, alice, bob, aliceOfferSeq, bobOfferSeq);
return badRate;
};
// bob's offer (the new offer) is the same every time:
Amounts const bobsOffer{
STAmount(XRP(1)), STAmount(USD.issue(), 1, 0)};
// alice's offer has a slightly smaller TakerPays with each
// iteration. This should mean that the size of the offer bob
// places in the ledger should increase with each iteration.
unsigned int blockedCount = 0;
for (std::uint64_t mantissaReduce = 1'000'000'000ull;
mantissaReduce <= 5'000'000'000ull;
mantissaReduce += 20'000'000ull)
{
STAmount aliceUSD{
bobsOffer.out.issue(),
bobsOffer.out.mantissa() - mantissaReduce,
bobsOffer.out.exponent()};
STAmount aliceXRP{
bobsOffer.in.issue(), bobsOffer.in.mantissa() - 1};
Amounts alicesOffer{aliceUSD, aliceXRP};
blockedCount += exerciseOfferPair(alicesOffer, bobsOffer);
}
// If fixReducedOffersV1 is enabled, then none of the test cases
// should produce a potentially blocking rate.
//
// Also verify that if fixReducedOffersV1 is not enabled then
// some of the test cases produced a potentially blocking rate.
if (features[fixReducedOffersV1])
{
BEAST_EXPECT(blockedCount == 0);
}
else
{
BEAST_EXPECT(blockedCount >= 170);
}
}
}
void
testPartialCrossOldXrpIouQChange()
{
testcase("exercise partial cross old XRP/IOU offer Q change");
using namespace jtx;
auto const gw = Account{"gateway"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
// Make one test run without fixReducedOffersV1 and one with.
for (FeatureBitset features :
{supported_amendments() - fixReducedOffersV1,
supported_amendments() | fixReducedOffersV1})
{
// Make sure none of the offers we generate are under funded.
Env env{*this, features};
env.fund(XRP(10'000'000), gw, alice, bob);
env.close();
env(trust(alice, USD(10'000'000)));
env(trust(bob, USD(10'000'000)));
env.close();
env(pay(gw, alice, USD(10'000'000)));
env.close();
// Lambda that:
// 1. Exercises one offer pair,
// 2. Collects the results, and
// 3. Cleans up for the next offer pair.
auto exerciseOfferPair =
[this, &env, &alice, &bob](
Amounts const& inLedger,
Amounts const& newOffer) -> unsigned int {
// Get the inLedger offer into the ledger so newOffer can cross
// it.
STAmount const initialRate = Quality(inLedger).rate();
std::uint32_t const aliceOfferSeq = env.seq(alice);
env(offer(alice, inLedger.in, inLedger.out));
env.close();
// Now bob's offer will partially cross alice's offer.
std::uint32_t const bobOfferSeq = env.seq(bob);
STAmount const aliceInitialBalance = env.balance(alice);
env(offer(bob, newOffer.in, newOffer.out));
env.close();
STAmount const aliceFinalBalance = env.balance(alice);
// bob's offer should not have made it into the ledger.
if (!BEAST_EXPECT(!offerInLedger(env, bob, bobOfferSeq)))
{
// If the in-ledger offer was not consumed then further
// results are meaningless.
cleanupOldOffers(
env, alice, bob, aliceOfferSeq, bobOfferSeq);
return 1;
}
// alice's offer should still be in the ledger, but reduced in
// size.
unsigned int badRate = 1;
{
Json::Value aliceOffer =
ledgerEntryOffer(env, alice, aliceOfferSeq);
STAmount const reducedTakerGets = amountFromJson(
sfTakerGets,
aliceOffer[jss::node][sfTakerGets.jsonName]);
STAmount const reducedTakerPays = amountFromJson(
sfTakerPays,
aliceOffer[jss::node][sfTakerPays.jsonName]);
STAmount const aliceGot =
env.balance(alice) - aliceInitialBalance;
BEAST_EXPECT(reducedTakerPays < inLedger.in);
BEAST_EXPECT(reducedTakerGets < inLedger.out);
STAmount const inLedgerRate =
Quality(Amounts{reducedTakerPays, reducedTakerGets})
.rate();
badRate = inLedgerRate > initialRate ? 1 : 0;
// If the inLedgerRate is less than initial rate, then
// incrementing the mantissa of the reduced taker pays
// should result in a rate higher than initial. Check
// this to verify that the largest allowable TakerPays
// was computed.
if (badRate == 0)
{
STAmount const tweakedTakerPays =
reducedTakerPays + drops(1);
STAmount const tweakedRate =
Quality(Amounts{tweakedTakerPays, reducedTakerGets})
.rate();
BEAST_EXPECT(tweakedRate > initialRate);
}
#if 0
std::cout << "Placed rate: " << initialRate
<< "; in-ledger rate: " << inLedgerRate
<< "; TakerPays: " << reducedTakerPays
<< "; TakerGets: " << reducedTakerGets
<< "; alice already got: " << aliceGot
<< std::endl;
// #else
std::string_view filler = badRate ? "**" : " ";
std::cout << "| `" << reducedTakerGets << "` | `"
<< reducedTakerPays << "` | `" << initialRate
<< "` | " << filler << "`" << inLedgerRate << "`"
<< filler << " | `" << aliceGot << "` |"
<< std::endl;
#endif
}
// In preparation for the next iteration make sure the two
// offers are gone from the ledger.
cleanupOldOffers(env, alice, bob, aliceOfferSeq, bobOfferSeq);
return badRate;
};
// alice's offer (the old offer) is the same every time:
Amounts const aliceOffer{
STAmount(XRP(1)), STAmount(USD.issue(), 1, 0)};
// bob's offer has a slightly smaller TakerPays with each iteration.
// This should mean that the size of the offer alice leaves in the
// ledger should increase with each iteration.
unsigned int blockedCount = 0;
for (std::uint64_t mantissaReduce = 1'000'000'000ull;
mantissaReduce <= 4'000'000'000ull;
mantissaReduce += 20'000'000ull)
{
STAmount bobUSD{
aliceOffer.out.issue(),
aliceOffer.out.mantissa() - mantissaReduce,
aliceOffer.out.exponent()};
STAmount bobXRP{
aliceOffer.in.issue(), aliceOffer.in.mantissa() - 1};
Amounts bobsOffer{bobUSD, bobXRP};
blockedCount += exerciseOfferPair(aliceOffer, bobsOffer);
}
// If fixReducedOffersV1 is enabled, then none of the test cases
// should produce a potentially blocking rate.
//
// Also verify that if fixReducedOffersV1 is not enabled then
// some of the test cases produced a potentially blocking rate.
if (features[fixReducedOffersV1])
{
BEAST_EXPECT(blockedCount == 0);
}
else
{
BEAST_EXPECT(blockedCount > 10);
}
}
}
void
testUnderFundedXrpIouQChange()
{
testcase("exercise underfunded XRP/IOU offer Q change");
// Bob places an offer that is not fully funded.
//
// This unit test compares the behavior of this situation before and
// after applying the fixReducedOffersV1 amendment.
using namespace jtx;
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const gw = Account{"gw"};
auto const USD = gw["USD"];
// Make one test run without fixReducedOffersV1 and one with.
for (FeatureBitset features :
{supported_amendments() - fixReducedOffersV1,
supported_amendments() | fixReducedOffersV1})
{
Env env{*this, features};
env.fund(XRP(10000), alice, bob, gw);
env.close();
env.trust(USD(1000), alice, bob);
int blockedOrderBookCount = 0;
for (STAmount initialBobUSD = USD(0.45); initialBobUSD <= USD(1);
initialBobUSD += USD(0.025))
{
// underfund bob's offer
env(pay(gw, bob, initialBobUSD));
env.close();
std::uint32_t const bobOfferSeq = env.seq(bob);
env(offer(bob, drops(2), USD(1)));
env.close();
// alice places an offer that would cross bob's if bob's were
// well funded.
std::uint32_t const aliceOfferSeq = env.seq(alice);
env(offer(alice, USD(1), drops(2)));
env.close();
// We want to detect order book blocking. If:
// 1. bob's offer is still in the ledger and
// 2. alice received no USD
// then we use that as evidence that bob's offer blocked the
// order book.
{
bool const bobsOfferGone =
!offerInLedger(env, bob, bobOfferSeq);
STAmount const aliceBalanceUSD = env.balance(alice, USD);
// Sanity check the ledger if alice got USD.
if (aliceBalanceUSD.signum() > 0)
{
BEAST_EXPECT(aliceBalanceUSD == initialBobUSD);
BEAST_EXPECT(env.balance(bob, USD) == USD(0));
BEAST_EXPECT(bobsOfferGone);
}
// Track occurrences of order book blocking.
if (!bobsOfferGone && aliceBalanceUSD.signum() == 0)
{
++blockedOrderBookCount;
}
// In preparation for the next iteration clean up any
// leftover offers.
cleanupOldOffers(
env, alice, bob, aliceOfferSeq, bobOfferSeq);
// Zero out alice's and bob's USD balances.
if (STAmount const aliceBalance = env.balance(alice, USD);
aliceBalance.signum() > 0)
env(pay(alice, gw, aliceBalance));
if (STAmount const bobBalance = env.balance(bob, USD);
bobBalance.signum() > 0)
env(pay(bob, gw, bobBalance));
env.close();
}
}
// If fixReducedOffersV1 is enabled, then none of the test cases
// should produce a potentially blocking rate.
//
// Also verify that if fixReducedOffersV1 is not enabled then
// some of the test cases produced a potentially blocking rate.
if (features[fixReducedOffersV1])
{
BEAST_EXPECT(blockedOrderBookCount == 0);
}
else
{
BEAST_EXPECT(blockedOrderBookCount > 15);
}
}
}
void
testUnderFundedIouIouQChange()
{
testcase("exercise underfunded IOU/IOU offer Q change");
// Bob places an IOU/IOU offer that is not fully funded.
//
// This unit test compares the behavior of this situation before and
// after applying the fixReducedOffersV1 amendment.
using namespace jtx;
using namespace std::chrono_literals;
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const gw = Account{"gw"};
auto const USD = gw["USD"];
auto const EUR = gw["EUR"];
STAmount const tinyUSD(USD.issue(), /*mantissa*/ 1, /*exponent*/ -81);
// Make one test run without fixReducedOffersV1 and one with.
for (FeatureBitset features :
{supported_amendments() - fixReducedOffersV1,
supported_amendments() | fixReducedOffersV1})
{
Env env{*this, features};
env.fund(XRP(10000), alice, bob, gw);
env.close();
env.trust(USD(1000), alice, bob);
env.trust(EUR(1000), alice, bob);
STAmount const eurOffer(
EUR.issue(), /*mantissa*/ 2957, /*exponent*/ -76);
STAmount const usdOffer(
USD.issue(), /*mantissa*/ 7109, /*exponent*/ -76);
STAmount const endLoop(
USD.issue(), /*mantissa*/ 50, /*exponent*/ -81);
int blockedOrderBookCount = 0;
for (STAmount initialBobUSD = tinyUSD; initialBobUSD <= endLoop;
initialBobUSD += tinyUSD)
{
// underfund bob's offer
env(pay(gw, bob, initialBobUSD));
env(pay(gw, alice, EUR(100)));
env.close();
// This offer is underfunded
std::uint32_t bobOfferSeq = env.seq(bob);
env(offer(bob, eurOffer, usdOffer));
env.close();
env.require(offers(bob, 1));
// alice places an offer that crosses bob's.
std::uint32_t aliceOfferSeq = env.seq(alice);
env(offer(alice, usdOffer, eurOffer));
env.close();
// Examine the aftermath of alice's offer.
{
bool const bobsOfferGone =
!offerInLedger(env, bob, bobOfferSeq);
STAmount aliceBalanceUSD = env.balance(alice, USD);
#if 0
std::cout
<< "bobs initial: " << initialBobUSD
<< "; alice final: " << aliceBalanceUSD
<< "; bobs offer: " << bobsOfferJson.toStyledString()
<< std::endl;
#endif
// Sanity check the ledger if alice got USD.
if (aliceBalanceUSD.signum() > 0)
{
BEAST_EXPECT(aliceBalanceUSD == initialBobUSD);
BEAST_EXPECT(env.balance(bob, USD) == USD(0));
BEAST_EXPECT(bobsOfferGone);
}
// Track occurrences of order book blocking.
if (!bobsOfferGone && aliceBalanceUSD.signum() == 0)
{
++blockedOrderBookCount;
}
}
// In preparation for the next iteration clean up any
// leftover offers.
cleanupOldOffers(env, alice, bob, aliceOfferSeq, bobOfferSeq);
// Zero out alice's and bob's IOU balances.
auto zeroBalance = [&env, &gw](
Account const& acct, IOU const& iou) {
if (STAmount const balance = env.balance(acct, iou);
balance.signum() > 0)
env(pay(acct, gw, balance));
};
zeroBalance(alice, EUR);
zeroBalance(alice, USD);
zeroBalance(bob, EUR);
zeroBalance(bob, USD);
env.close();
}
// If fixReducedOffersV1 is enabled, then none of the test cases
// should produce a potentially blocking rate.
//
// Also verify that if fixReducedOffersV1 is not enabled then
// some of the test cases produced a potentially blocking rate.
if (features[fixReducedOffersV1])
{
BEAST_EXPECT(blockedOrderBookCount == 0);
}
else
{
BEAST_EXPECT(blockedOrderBookCount > 20);
}
}
}
void
run() override
{
testPartialCrossNewXrpIouQChange();
testPartialCrossOldXrpIouQChange();
testUnderFundedXrpIouQChange();
testUnderFundedIouIouQChange();
}
};
BEAST_DEFINE_TESTSUITE_PRIO(ReducedOffer, tx, ripple, 2);
} // namespace test
} // namespace ripple