Prevent zero-input MPT offer fills after clipping

This commit is contained in:
Gregory Tsipenyuk
2026-05-16 14:46:01 -04:00
parent 01164a3ec0
commit c6918f5915
2 changed files with 119 additions and 9 deletions

View File

@@ -139,17 +139,17 @@ template <class TTakerPays, class TTakerGets>
TOfferStreamBase<TIn, TOut>::shouldRmSmallIncreasedQOffer() const
{
// Consider removing the offer if:
// o `TakerPays` is XRP (because of XRP drops granularity) or
// o `TakerPays` is integral (because XRP/MPT have indivisible units) or
// o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets`
constexpr bool const kIN_IS_XRP = std::is_same_v<TTakerPays, XRPAmount>;
constexpr bool const kOUT_IS_XRP = std::is_same_v<TTakerGets, XRPAmount>;
constexpr bool const kIN_IS_INTEGRAL = !std::is_same_v<TTakerPays, IOUAmount>;
constexpr bool const kOUT_IS_INTEGRAL = !std::is_same_v<TTakerGets, IOUAmount>;
if constexpr (kOUT_IS_XRP)
if constexpr (!kIN_IS_INTEGRAL && kOUT_IS_INTEGRAL)
{
// If `TakerGets` is XRP, the worst this offer's quality can change is
// to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be
// remarkably good quality for any realistic asset, so these offers
// don't need this extra check.
// If only `TakerGets` is integral, the worst this offer's quality can
// change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This
// will be perfect quality for any realistic asset, so these
// offers don't need this extra check.
return false;
}
@@ -159,7 +159,7 @@ TOfferStreamBase<TIn, TOut>::shouldRmSmallIncreasedQOffer() const
TAmounts<TTakerPays, TTakerGets> const ofrAmts{
toAmount<TTakerPays>(offer_.amount().in), toAmount<TTakerGets>(offer_.amount().out)};
if constexpr (!kIN_IS_XRP && !kOUT_IS_XRP)
if constexpr (!kIN_IS_INTEGRAL && !kOUT_IS_INTEGRAL)
{
if (Number(ofrAmts.in) >= Number(ofrAmts.out))
return false;

View File

@@ -608,6 +608,115 @@ public:
testHelper2TokensMix(test);
}
void
testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features)
{
using namespace jtx;
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
{
testcase("Partially funded MPT/XRP input offer cannot be consumed for free");
Env env{*this, features};
auto const gw = Account{"gw"};
env.fund(XRP(10'000), gw, alice, bob);
env.close();
MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}});
env(offer(alice, usd(1), drops(1'000'000)));
env.close();
auto const targetBalance = reserve(env, 2) + drops(999'999);
auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() -
env.current()->fees().base;
env(pay(alice, gw, drops(drain)));
env.close();
auto const aliceXRPBefore = env.balance(alice);
auto const bobXRPBefore = env.balance(bob);
env(pay(gw, bob, drops(1'000'000)),
Sendmax(usd(1)),
Path(~XRP),
Txflags(tfNoRippleDirect | tfPartialPayment),
Ter(tecPATH_DRY));
env.close();
BEAST_EXPECT(env.balance(alice) == aliceXRPBefore);
BEAST_EXPECT(env.balance(bob) == bobXRPBefore);
}
{
testcase("Partially funded MPT/IOU input offer cannot be consumed for free");
Env env{*this, features};
auto const mptIssuer = Account{"mptIssuer"};
auto const iouIssuer = Account{"iouIssuer"};
env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob);
env.close();
auto const eur = iouIssuer["EUR"];
env.trust(eur(100), alice, bob);
env(pay(iouIssuer, alice, eur(0.5)));
env.close();
MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}});
env(offer(alice, usd(1), eur(1)));
env.close();
auto const aliceEURBefore = env.balance(alice, eur);
auto const bobEURBefore = env.balance(bob, eur);
env(pay(mptIssuer, bob, eur(1)),
Sendmax(usd(1)),
Path(~eur),
Txflags(tfNoRippleDirect | tfPartialPayment),
Ter(tecPATH_DRY));
env.close();
BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore);
BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore);
}
{
testcase("Partially funded MPT/MPT input offer cannot be consumed for free");
Env env{*this, features};
auto const issuerA = Account{"issuerA"};
auto const issuerB = Account{"issuerB"};
env.fund(XRP(10'000), issuerA, issuerB, alice, bob);
env.close();
MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}});
MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}});
env(pay(issuerB, alice, eur(999'999)));
env.close();
env(offer(alice, usd(1), eur(1'000'000)));
env.close();
auto const aliceEURBefore = eur.getBalance(alice);
auto const bobEURBefore = eur.getBalance(bob);
env(pay(issuerA, bob, eur(1'000'000)),
Sendmax(usd(1)),
Path(~eur),
Txflags(tfNoRippleDirect | tfPartialPayment),
Ter(tecPATH_DRY));
env.close();
BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore));
BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore));
}
}
void
testInsufficientReserve(FeatureBitset features)
{
@@ -4958,6 +5067,7 @@ public:
testTicketCancelOffer(features);
testRmSmallIncreasedQOffersXRP(features);
testRmSmallIncreasedQOffersMPT(features);
testPartiallyFundedMPTInputOfferZeroInput(features);
testFillOrKill(features);
testTickSize(features);
}