From d0dbf9163c66288e37d1c5bc9dce313d457f732b Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:04:04 +0000 Subject: [PATCH] fix: Prevent AMM auction slots from being acquired at zero cost when trading fee is zero (#7430) --- include/xrpl/protocol/AMMCore.h | 11 +++++ src/libxrpl/tx/transactors/dex/AMMBid.cpp | 30 +++++++------ src/test/app/AMMMPT_test.cpp | 25 +++++++---- src/test/app/AMM_test.cpp | 51 +++++++++++++++++++---- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index 1e11f6cd8b..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -91,6 +91,17 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAuctionSlotFeeScaleFactor; } +/** + * Minimum auction slot price: LPTokens * TradingFee / kAuctionSlotMinFeeFraction + * @param lptAMMBalance AMM LP token balance + * @param tradingFee trading fee in {0, 1000} + */ +inline Number +ammAuctionMinSlotPrice(Number const& lptAMMBalance, std::uint16_t tradingFee) +{ + return lptAMMBalance * getFee(tradingFee) / kAuctionSlotMinFeeFraction; +} + /** * Get fee multiplier (1 - tfee) * @tfee trading fee in basis points diff --git a/src/libxrpl/tx/transactors/dex/AMMBid.cpp b/src/libxrpl/tx/transactors/dex/AMMBid.cpp index 3454559e82..154e64ca8e 100644 --- a/src/libxrpl/tx/transactors/dex/AMMBid.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMBid.cpp @@ -193,10 +193,10 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const current = duration_cast(ctx.view().header().parentCloseTime.time_since_epoch()).count(); // Auction slot discounted fee - auto const discountedFee = (*ammSle)[sfTradingFee] / kAuctionSlotDiscountedFeeFraction; - auto const tradingFee = getFee((*ammSle)[sfTradingFee]); + auto const ammTradingFee = (*ammSle)[sfTradingFee]; + auto const discountedFee = ammTradingFee / kAuctionSlotDiscountedFeeFraction; // Min price - auto const minSlotPrice = lptAMMBalance * tradingFee / kAuctionSlotMinFeeFraction; + auto const minSlotPrice = ammAuctionMinSlotPrice(lptAMMBalance, ammTradingFee); static constexpr std::uint32_t kTailingSlot = kAuctionSlotTimeIntervals - 1; @@ -260,31 +260,37 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const bidMax = ctx.tx[~sfBidMax]; auto getPayPrice = [&](Number const& computedPrice) -> std::expected { + auto effectivePrice = computedPrice; + if (ctx.view().rules().enabled(fixCleanup3_4_0) && ammTradingFee == 0) + { + // Prevent zero-fee pools from granting auction slots at zero or dust prices. + effectivePrice = std::max(effectivePrice, ammAuctionMinSlotPrice(lptAMMBalance, 1)); + } auto const payPrice = [&]() -> std::optional { // Both min/max bid price are defined if (bidMin && bidMax) { - if (computedPrice <= *bidMax) - return std::max(computedPrice, Number(*bidMin)); - JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << computedPrice << " " + if (effectivePrice <= *bidMax) + return std::max(effectivePrice, Number(*bidMin)); + JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << effectivePrice << " " << *bidMin << " " << *bidMax; return std::nullopt; } - // Bidder pays max(bidPrice, computedPrice) + // Bidder pays max(bidPrice, effectivePrice) if (bidMin) { - return std::max(computedPrice, Number(*bidMin)); + return std::max(effectivePrice, Number(*bidMin)); } if (bidMax) { - if (computedPrice <= *bidMax) - return computedPrice; + if (effectivePrice <= *bidMax) + return effectivePrice; JLOG(ctx.journal.debug()) - << "AMM Bid: not in range " << computedPrice << " " << *bidMax; + << "AMM Bid: not in range " << effectivePrice << " " << *bidMax; return std::nullopt; } - return computedPrice; + return effectivePrice; }(); if (!payPrice) { diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index bfd2d529b5..ac9728ede1 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -3992,24 +3992,30 @@ private: [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0); + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, {{gAmmmpt(10'000'000'000), USD(10'000)}}); @@ -7489,6 +7495,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testClawback(); testClawbackFromAMMAccount(all); testClawbackFromAMMAccount(all - featureSingleAssetVault); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index e1732aaf0e..0212035c6e 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -3127,27 +3127,59 @@ private: std::nullopt, {features}); + // Zero-fee bid without an explicit price pays a floor with fixCleanup3_4_0. + testAMM( + [&](AMM& ammAlice, Env& env) { + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const expectedPrice = cleanup340 ? minBidPrice : IOUAmount{0}; + auto const expectedTokens = cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens(); + + env.close(seconds(kTotalTimeSlotSecs + 1)); + env.close(); + env(ammAlice.bid({.account = alice_})); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, expectedPrice)); + BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), expectedTokens)); + + ammAlice.vote(alice_, 1'000); + BEAST_EXPECT(ammAlice.expectAuctionSlot(100, 0, expectedPrice)); + }, + std::nullopt, + 0, + std::nullopt, + {features}); + // Bid tiny amount testAMM( [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, std::nullopt, 0, @@ -7436,6 +7468,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testBid(all - fixAMMv1_3); testBid(all - fixAMMv1_1 - fixAMMv1_3); testInvalidAMMPayment();