Refine AMM offer generation for integral MPT pairs

This commit is contained in:
Gregory Tsipenyuk
2026-05-16 19:33:00 -04:00
parent c6918f5915
commit a27596f988
2 changed files with 101 additions and 11 deletions

View File

@@ -200,7 +200,7 @@ getAMMOfferStartWithTakerGets(
auto getAmounts = [&pool, &tfee](Number const& nTakerGetsProposed) {
// Round downward to minimize the offer and to maximize the quality.
// This has the most impact when takerGets is XRP.
// This has the most impact when takerGets is integral.
auto const takerGets =
toAmount<TOut>(getAsset(pool.out), nTakerGetsProposed, Number::RoundingMode::Downward);
return TAmounts<TIn, TOut>{swapAssetOut(pool, takerGets, tfee), takerGets};
@@ -215,7 +215,7 @@ getAMMOfferStartWithTakerGets(
}
/** Generate AMM offer starting with takerPays when AMM pool
* from the payment perspective is XRP(in)/IOU(out) or IOU(in)/IOU(out).
* from the payment perspective has a fractional output asset.
* Equations:
* Spot Price Quality after the offer is consumed:
* Qsp = (O - o) / (I + i) -- equation (1)
@@ -267,7 +267,7 @@ getAMMOfferStartWithTakerPays(
auto getAmounts = [&pool, &tfee](Number const& nTakerPaysProposed) {
// Round downward to minimize the offer and to maximize the quality.
// This has the most impact when takerPays is XRP.
// This has the most impact when takerPays is integral.
auto const takerPays =
toAmount<TIn>(getAsset(pool.in), nTakerPaysProposed, Number::RoundingMode::Downward);
return TAmounts<TIn, TOut>{takerPays, swapAssetIn(pool, takerPays, tfee)};
@@ -285,11 +285,11 @@ getAMMOfferStartWithTakerPays(
* is equal to LOB quality (in this case AMM offer quality is
* better than LOB quality) or AMM offer is equal to LOB quality
* (in this case SPQ is better than LOB quality).
* Pre-amendment code calculates takerPays first. If takerGets is XRP,
* it is rounded down, which results in worse offer quality than
* LOB quality, and the offer might fail to generate.
* Post-amendment code calculates the XRP offer side first. The result
* is rounded down, which makes the offer quality better.
* Pre-amendment code calculates takerPays first. If takerGets is the
* economically coarser integral side, it is rounded down, which results in
* worse offer quality than LOB quality, and the offer might fail to generate.
* Post-amendment code calculates the economically coarser integral offer side
* first. The result is rounded down, which makes the offer quality better.
* It might not be possible to match either SPQ or AMM offer to LOB
* quality. This generally happens at higher fees.
* @param pool AMM pool balances
@@ -368,10 +368,18 @@ changeSpotPriceQuality(
return std::nullopt;
}
// Generate the offer starting with XRP side. Return seated offer amounts
// if the offer can be generated, otherwise nullopt.
auto amounts = [&]() {
if (isXRP(getAsset(pool.out)))
bool const inIntegral = getAsset(pool.in).integral();
bool const outIntegral = getAsset(pool.out).integral();
// Preserve historical behavior for fractional pairs and XRP/IOU-style
// one-integral-side pairs. For two integral assets, pick the side whose
// minimum unit is economically coarser at this quality.
//
// Quality::rate() is input units per output unit, so one output unit is
// coarser when it costs at least one input unit. Ties use takerGets,
// matching the historical XRP-output behavior.
if (outIntegral && (!inIntegral || Number(quality.rate()) >= 1))
return getAMMOfferStartWithTakerGets(pool, quality, tfee);
return getAMMOfferStartWithTakerPays(pool, quality, tfee);
}();

View File

@@ -5645,6 +5645,87 @@ private:
});
}
void
testAMMOfferGenerationPolicy(FeatureBitset features)
{
testcase("AMM payment offer generation picks economically coarser integral side");
using namespace jtx;
enum class GeneratedFirst { TakerPays, TakerGets };
auto const check = [&](std::uint64_t mptUnitsPerXRP, GeneratedFirst generatedFirst) {
TAmounts<XRPAmount, MPTAmount> const pool{
XRPAmount{1'000'000}, MPTAmount{1'000'000'125}};
TAmounts<XRPAmount, MPTAmount> const clobOffer{
kDROPS_PER_XRP, MPTAmount{static_cast<std::int64_t>(mptUnitsPerXRP)}};
Quality const clobQuality{clobOffer};
auto const expectedAmounts = generatedFirst == GeneratedFirst::TakerGets
? getAMMOfferStartWithTakerGets(pool, clobQuality, 0)
: getAMMOfferStartWithTakerPays(pool, clobQuality, 0);
auto const otherAmounts = generatedFirst == GeneratedFirst::TakerGets
? getAMMOfferStartWithTakerPays(pool, clobQuality, 0)
: getAMMOfferStartWithTakerGets(pool, clobQuality, 0);
BEAST_EXPECT(expectedAmounts);
BEAST_EXPECT(otherAmounts);
if (!expectedAmounts || !otherAmounts)
return;
// Make the tested branch observable: these cases are chosen so the
// payment consumes different AMM amounts depending on which side
// is generated first.
BEAST_EXPECT(*expectedAmounts != *otherAmounts);
Env env(*this, features);
auto const gw = Account("gw");
auto const lp = Account("lp");
auto const maker = Account("maker");
auto const taker = Account("taker");
auto const dst = Account("dst");
env.fund(XRP(10'000), gw, lp, maker, taker, dst);
env.close();
MPTTester const token(
{.env = env, .issuer = gw, .holders = {lp, maker, dst}, .flags = kMPT_DEX_FLAGS});
env(pay(gw, lp, token(pool.out.value())));
env(pay(gw, maker, token(10'000'000)));
env.close();
AMM const amm(env, lp, drops(pool.in), token(pool.out.value()));
auto const makerOfferSeq = env.seq(maker);
env(offer(maker, XRP(1), token(mptUnitsPerXRP)), Txflags(tfPassive));
env.close();
env(pay(taker, dst, token(expectedAmounts->out.value())),
Sendmax(drops(expectedAmounts->in)));
env.close();
BEAST_EXPECT(amm.expectBalances(
drops(pool.in + expectedAmounts->in),
token((pool.out - expectedAmounts->out).value()),
amm.tokens()));
env.require(Balance(dst, token(expectedAmounts->out.value())));
BEAST_EXPECT(env.le(keylet::offer(maker.id(), makerOfferSeq)));
};
// CLOB price: 10'000'000 MPT per 1 XRP, so one raw MPT unit is worth
// 0.1 drops. One drop is the economically coarser unit and the AMM
// offer is generated from takerPays.
check(10 * kDROPS_PER_XRP.drops(), GeneratedFirst::TakerPays);
// CLOB price: 1'000'000 MPT per 1 XRP, so one raw MPT unit is worth
// one drop. Ties use takerGets to preserve the historical XRP-output
// behavior.
check(kDROPS_PER_XRP.drops(), GeneratedFirst::TakerGets);
// CLOB price: 100'000 MPT per 1 XRP, so one raw MPT unit is worth
// 10 drops. MPT is the economically coarser unit and the AMM offer is
// generated from takerGets.
check(kDROPS_PER_XRP.drops() / 10, GeneratedFirst::TakerGets);
}
void
testTradingFee(FeatureBitset features)
{
@@ -7101,6 +7182,7 @@ private:
testAMMTokens();
testAmendment();
testAMMAndCLOB(all);
testAMMOfferGenerationPolicy(all);
testTradingFee(all);
testTradingFee(all - fixAMMv1_3);
testAdjustedTokens(all);