Skip to content

Commit

Permalink
fix(CL): UpdatePosition overwrites pool state with old last liquidity…
Browse files Browse the repository at this point in the history
… update, leading to incentives overlclaiming (#5093)

* updates

* fix(CL): UpdatePosition overwrites pool state witl old last liquidity update

* add test

* updates

* comment

* uusdc

* comment
  • Loading branch information
p0mvn authored May 9, 2023
1 parent a131abf commit 6c00588
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 31 deletions.
2 changes: 2 additions & 0 deletions x/concentrated-liquidity/fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ func (k Keeper) initOrUpdateFeeAccumulatorPosition(ctx sdk.Context, poolId uint6
}

// getFeeGrowthOutside returns the sum of fee growth above the upper tick and fee growth below the lower tick
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
// Currently, Tte call to GetTickInfo() may mutate state.
func (k Keeper) getFeeGrowthOutside(ctx sdk.Context, poolId uint64, lowerTick, upperTick int64) (sdk.DecCoins, error) {
pool, err := k.getPoolById(ctx, poolId)
if err != nil {
Expand Down
9 changes: 9 additions & 0 deletions x/concentrated-liquidity/incentives.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ func (k Keeper) claimAndResetFullRangeBalancerPool(ctx sdk.Context, clPoolId uin
// Specifically, it gets the time elapsed since the last update and divides it
// by the qualifying liquidity for each uptime. It then adds this value to the
// respective accumulator and updates relevant time trackers accordingly.
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
func (k Keeper) updateUptimeAccumulatorsToNow(ctx sdk.Context, poolId uint64) error {
pool, err := k.getPoolById(ctx, poolId)
if err != nil {
Expand Down Expand Up @@ -554,6 +555,8 @@ func (k Keeper) getAllIncentiveRecordsForUptime(ctx sdk.Context, poolId uint64,
// UptimeGrowthInside tracks the incentives accured by a specific LP within a pool. It keeps track of the cumulative amount of incentives
// collected by a specific LP within a pool. This function also measures the growth of incentives accured by a particular LP since the last
// time incentives were collected.
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
// The mutation occurs in the call to GetTickInfo().
func (k Keeper) GetUptimeGrowthInsideRange(ctx sdk.Context, poolId uint64, lowerTick int64, upperTick int64) ([]sdk.DecCoins, error) {
pool, err := k.getPoolById(ctx, poolId)
if err != nil {
Expand Down Expand Up @@ -620,6 +623,7 @@ func (k Keeper) GetUptimeGrowthOutsideRange(ctx sdk.Context, poolId uint64, lowe
}

// initOrUpdatePositionUptime either adds or updates records for all uptime accumulators `position` qualifies for
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
func (k Keeper) initOrUpdatePositionUptime(ctx sdk.Context, poolId uint64, liquidity sdk.Dec, owner sdk.AccAddress, lowerTick, upperTick int64, liquidityDelta sdk.Dec, joinTime time.Time, positionId uint64) error {
// We update accumulators _prior_ to any position-related updates to ensure
// past rewards aren't distributed to new liquidity. We also update pool's
Expand Down Expand Up @@ -765,6 +769,10 @@ func (k Keeper) claimAllIncentivesForPosition(ctx sdk.Context, positionId uint64
return sdk.Coins{}, sdk.Coins{}, err
}

if err := k.updateUptimeAccumulatorsToNow(ctx, position.PoolId); err != nil {
return sdk.Coins{}, sdk.Coins{}, err
}

// Compute the age of the position.
positionAge := ctx.BlockTime().Sub(position.JoinTime)
if positionAge < 0 {
Expand Down Expand Up @@ -928,6 +936,7 @@ func (k Keeper) collectIncentives(ctx sdk.Context, sender sdk.AccAddress, positi
// - startTime is < blockTime.
// - minUptime is not an authorizedUptime.
// - other internal database or math errors.
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
func (k Keeper) CreateIncentive(ctx sdk.Context, poolId uint64, sender sdk.AccAddress, incentiveCoin sdk.Coin, emissionRate sdk.Dec, startTime time.Time, minUptime time.Duration) (types.IncentiveRecord, error) {
pool, err := k.getPoolById(ctx, poolId)
if err != nil {
Expand Down
93 changes: 93 additions & 0 deletions x/concentrated-liquidity/incentives_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3498,6 +3498,99 @@ func (s *KeeperTestSuite) TestQueryAndClaimAllIncentives() {
}
}

// This functional test focuses on changing liquidity in the same range and collecting incentives
// at different times.
// This is important because the final amount of incentives claimed depends on the last time when the pool
// was updated. We use this time to calculate the amount of incentives to emit into the uptime accumulators.
func (s *KeeperTestSuite) TestFunctional_ClaimIncentices_LiquidityChange_VaryingTime() {
// Init suite for the test.
s.SetupTest()

const (
testFullChargeDuration = 24 * time.Hour
)

var (
defaultAddress = s.TestAccs[0]
defaultBlockTime = time.Unix(1, 1).UTC()
)

s.Ctx = s.Ctx.WithBlockTime(defaultBlockTime)
requiredBalances := sdk.NewCoins(sdk.NewCoin(ETH, DefaultAmt0), sdk.NewCoin(USDC, DefaultAmt1))

// Set test authorized uptime params.
params := s.App.ConcentratedLiquidityKeeper.GetParams(s.Ctx)
params.AuthorizedUptimes = []time.Duration{time.Nanosecond, testFullChargeDuration}
s.App.ConcentratedLiquidityKeeper.SetParams(s.Ctx, params)

// Fund accounts twice because two positions are created.
s.FundAcc(defaultAddress, requiredBalances)
s.FundAcc(defaultAddress, requiredBalances)

// Create CL pool
pool := s.PrepareConcentratedPool()

expectedAmount := sdk.NewInt(60 * 60 * 24) // 1 day in seconds * 1 per second

oneUUSDCCoin := sdk.NewCoin(USDC, sdk.OneInt())
// -1 for acceptable rounding error
expectedCoinsPerFullCharge := sdk.NewCoins(sdk.NewCoin(USDC, expectedAmount.Sub(sdk.OneInt())))
expectedHalfOfExpectedCoinsPerFullCharge := sdk.NewCoins(sdk.NewCoin(USDC, expectedAmount.QuoRaw(2).Sub(sdk.OneInt())))

// Multiplied by 3 because we change the block time 3 times and claim
// 1. by directly calling CollectIncentives
// 2. by calling WithdrawPosition
// 3. by calling CollectIncentives
s.FundAcc(pool.GetIncentivesAddress(), sdk.NewCoins(sdk.NewCoin(USDC, expectedAmount.Mul(sdk.NewInt(3)))))
// Set incentives for pool to ensure accumulators work correctly
testIncentiveRecord := types.IncentiveRecord{
PoolId: 1,
IncentiveDenom: USDC,
IncentiveCreatorAddr: s.TestAccs[0].String(),
IncentiveRecordBody: types.IncentiveRecordBody{
RemainingAmount: sdk.NewDec(1000000000000000000),
EmissionRate: sdk.NewDec(1), // 1 per second
StartTime: defaultBlockTime,
},
MinUptime: time.Nanosecond,
}
s.App.ConcentratedLiquidityKeeper.SetMultipleIncentiveRecords(s.Ctx, []types.IncentiveRecord{testIncentiveRecord})

// Set up position
positionIdOne, _, _, _, _, err := s.App.ConcentratedLiquidityKeeper.CreatePosition(s.Ctx, defaultPoolId, defaultAddress, DefaultCoin0.Amount, DefaultCoin1.Amount, sdk.ZeroInt(), sdk.ZeroInt(), DefaultLowerTick, DefaultUpperTick)
s.Require().NoError(err)

// Increase block time by the fully charged duration (first time)
s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(testFullChargeDuration))

// Claim incentives.
collected, _, err := s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, positionIdOne)
s.Require().NoError(err)
s.Require().Equal(expectedCoinsPerFullCharge.String(), collected.String())

// Increase block time by the fully charged duration (second time)
s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(testFullChargeDuration))

// Create another position
positionIdTwo, _, _, _, _, err := s.App.ConcentratedLiquidityKeeper.CreatePosition(s.Ctx, defaultPoolId, defaultAddress, DefaultCoin0.Amount, DefaultCoin1.Amount, sdk.ZeroInt(), sdk.ZeroInt(), DefaultLowerTick, DefaultUpperTick)
s.Require().NoError(err)

// Increase block time by the fully charged duration (third time)
s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(testFullChargeDuration))

// Claim for second position. Must only claim half of the original expected amount since now there are 2 positions.
collected, _, err = s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, positionIdTwo)
s.Require().NoError(err)
s.Require().Equal(expectedHalfOfExpectedCoinsPerFullCharge.String(), collected.String())

// Claim for first position and observe that claims full expected charge for the period between 1st claim and 2nd position creation
// and half of the full charge amount since the 2nd position was created.
collected, _, err = s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, positionIdOne)
s.Require().NoError(err)
// Note, adding one since both expected amounts already subtract one (-2 in total)
s.Require().Equal(expectedCoinsPerFullCharge.Add(expectedHalfOfExpectedCoinsPerFullCharge.Add(oneUUSDCCoin)...).String(), collected.String())
}

func (s *KeeperTestSuite) TestGetAllIncentiveRecordsForUptime() {
invalidPoolId := uint64(2)
tests := map[string]struct {
Expand Down
2 changes: 1 addition & 1 deletion x/concentrated-liquidity/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var (
DefaultFeeAccumCoins = sdk.NewDecCoins(sdk.NewDecCoin("foo", sdk.NewInt(50)))
DefaultPositionId = uint64(1)
DefaultUnderlyingLockId = uint64(0)
DefaultJoinTime = time.Unix(0, 0)
DefaultJoinTime = time.Unix(0, 0).UTC()
ETH = "eth"
DefaultAmt0 = sdk.NewInt(1000000)
DefaultAmt0Expected = sdk.NewInt(998976)
Expand Down
8 changes: 8 additions & 0 deletions x/concentrated-liquidity/lp.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ func (k Keeper) addToPosition(ctx sdk.Context, owner sdk.AccAddress, positionId
// Updates ticks and pool liquidity. Returns how much of each token is either added or removed.
// Negative returned amounts imply that tokens are removed from the pool.
// Positive returned amounts imply that tokens are added to the pool.
// WARNING: this method may mutate the pool, make sure to refetch the pool after calling this method.
func (k Keeper) UpdatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddress, lowerTick, upperTick int64, liquidityDelta sdk.Dec, joinTime time.Time, positionId uint64) (sdk.Int, sdk.Int, error) {
if err := k.validatePositionUpdateById(ctx, positionId, owner, lowerTick, upperTick, liquidityDelta, joinTime, poolId); err != nil {
return sdk.Int{}, sdk.Int{}, err
Expand Down Expand Up @@ -343,6 +344,13 @@ func (k Keeper) UpdatePosition(ctx sdk.Context, poolId uint64, owner sdk.AccAddr
return sdk.Int{}, sdk.Int{}, err
}

// Refetch pool to get the updated pool.
// Note that updateUptimeAccumulatorsToNow may modify the pool state and rewrite it to the store.
pool, err = k.getPoolById(ctx, poolId)
if err != nil {
return sdk.Int{}, sdk.Int{}, err
}

// calculate the actual amounts of tokens 0 and 1 that were added or removed from the pool.
actualAmount0, actualAmount1, err := pool.CalcActualAmounts(ctx, lowerTick, upperTick, liquidityDelta)
if err != nil {
Expand Down
Loading

0 comments on commit 6c00588

Please sign in to comment.