diff --git a/fsm/dex.go b/fsm/dex.go index 89e022ee2c..41527a31a1 100644 --- a/fsm/dex.go +++ b/fsm/dex.go @@ -815,13 +815,21 @@ func liquidityDepositPoints(totalPoints, x, y, amount uint64) (uint64, lib.Error return lib.SafeMulDiv(totalPoints, newK-oldK, oldK), nil } -// migrateLegacyLPs() performs the one-time migration of a pool above the provider limit. +// migrateLegacyLPs() drains a pool sitting above the provider limit down toward MaxLiquidityProviders. +// The drain is bounded to MaxLPEvictionsPerBlock per block so a large legacy pool (e.g. one that reached the +// previous 50k limit) is pruned across multiple blocks rather than in a single oversized block that can't be +// applied within a consensus phase. It runs on every deposit-batch cycle until the pool reaches the cap. func (s *StateMachine) migrateLegacyLPs(batch *lib.DexBatch, p *Pool, chainId uint64, x, y *uint64, local bool) lib.ErrorI { // calculate how many legacy providers must be removed to reach the limit excess := len(p.Points) - lib.MaxLiquidityProviders if excess <= 0 { return nil } + // bound the per-block eviction work to the consensus round budget (mirrors MaxOrdersSettledPerBlock); + // the remaining excess is drained on subsequent blocks until the pool reaches the cap + if excess > lib.MaxLPEvictionsPerBlock { + excess = lib.MaxLPEvictionsPerBlock + } // rank the providers from lowest to highest points sort.Slice(p.Points, func(i, j int) bool { // rank by points first diff --git a/fsm/dex_test.go b/fsm/dex_test.go index c8a5462a6f..66342909f2 100644 --- a/fsm/dex_test.go +++ b/fsm/dex_test.go @@ -2898,7 +2898,8 @@ func TestHandleBatchDepositRanksNewcomersByProviderTotal(t *testing.T) { func TestHandleBatchDepositMigratesPoolBeforeDeposits(t *testing.T) { sm := newTestStateMachine(t) chainID := uint64(2) - points := make([]*lib.PoolPoints, 50_000) + const initialProviders = 50_000 + points := make([]*lib.PoolPoints, initialProviders) var total uint64 for i := range points { address := make([]byte, crypto.AddressSize) @@ -2912,17 +2913,36 @@ func TestHandleBatchDepositMigratesPoolBeforeDeposits(t *testing.T) { require.NoError(t, sm.SetPool(&Pool{Id: chainID + LiquidityPoolAddend, Amount: 1_000_000, Points: points, TotalPoolPoints: total})) x, y := uint64(1_000_000), uint64(1_000_000) - require.NoError(t, sm.HandleBatchDeposit(&lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{{ + // deposit from the highest-ranked provider so it always remains an incumbent across the drain + deposit := &lib.DexBatch{Deposits: []*lib.DexLiquidityDeposit{{ Address: points[len(points)-1].Address, Amount: 100, - }}}, chainID, &x, &y, false)) + }}} + // the drain is bounded to MaxLPEvictionsPerBlock per block: a single batch prunes exactly that many + // lowest-ranked providers, NOT the whole excess at once (which would produce an unappliable block) + require.NoError(t, sm.HandleBatchDeposit(deposit, chainID, &x, &y, false)) pool, err := sm.GetPool(chainID + LiquidityPoolAddend) require.NoError(t, err) - require.Len(t, pool.Points, lib.MaxLiquidityProviders) + require.Len(t, pool.Points, initialProviders-lib.MaxLPEvictionsPerBlock) + // the lowest-ranked providers are evicted first for _, evicted := range points[1:3] { _, err = pool.GetPointsFor(evicted.Address) require.Error(t, err) } + + // subsequent blocks continue draining MaxLPEvictionsPerBlock at a time until the pool reaches the cap + iterations := 1 + for len(pool.Points) > lib.MaxLiquidityProviders { + require.Less(t, iterations, 100, "drain failed to converge") + require.NoError(t, sm.HandleBatchDeposit(deposit, chainID, &x, &y, false)) + pool, err = sm.GetPool(chainID + LiquidityPoolAddend) + require.NoError(t, err) + iterations++ + } + require.Len(t, pool.Points, lib.MaxLiquidityProviders) + // the permanent dead address is never evicted + _, err = pool.GetPointsFor(deadAddr.Bytes()) + require.NoError(t, err) } func TestHandleBatchDepositUsesReceiptHashForEqualStakeTie(t *testing.T) { diff --git a/lib/certificate.go b/lib/certificate.go index f0a5a085b9..0181bafa4a 100644 --- a/lib/certificate.go +++ b/lib/certificate.go @@ -27,6 +27,12 @@ const ( // MaxOrdersSettledPerBlock caps DEX orders settled per block so begin_block can't exceed the consensus // round budget; orders beyond the cap are failed (receipt 0) and refunded to the seller on the origin chain MaxOrdersSettledPerBlock = 250 + // MaxLPEvictionsPerBlock caps how many legacy liquidity providers are force-evicted per block when + // draining a pool that sits above MaxLiquidityProviders. Like MaxOrdersSettledPerBlock, this bounds the + // per-block work so applying the block stays within the consensus round budget: an over-cap pool drains + // toward the limit across multiple blocks instead of in a single oversized block that can't be + // applied+voted within a consensus phase (which livelocks the chain). + MaxLPEvictionsPerBlock = 5_000 ) // MaxBlockHeaderSize is a consensus breaking change because it affects how the state machine