diff --git a/x/concentrated-liquidity/keeper_test.go b/x/concentrated-liquidity/keeper_test.go index 3bde22db31b..245fe1716e6 100644 --- a/x/concentrated-liquidity/keeper_test.go +++ b/x/concentrated-liquidity/keeper_test.go @@ -374,6 +374,14 @@ func (s *KeeperTestSuite) validatePositionSpreadRewardGrowth(poolId uint64, posi } } +func (s *KeeperTestSuite) SetBlockTime(timeToSet time.Time) { + s.Ctx = s.Ctx.WithBlockTime(timeToSet) +} + +func (s *KeeperTestSuite) AddBlockTime(timeToAdd time.Duration) { + s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(timeToAdd)) +} + func (s *KeeperTestSuite) TestValidatePermissionlessPoolCreationEnabled() { s.SetupTest() // Normally, by default, permissionless pool creation is disabled. @@ -389,6 +397,47 @@ func (s *KeeperTestSuite) TestValidatePermissionlessPoolCreationEnabled() { s.Require().Error(s.App.ConcentratedLiquidityKeeper.ValidatePermissionlessPoolCreationEnabled(s.Ctx)) } +// runFungifySetup Sets up a pool with `poolSpreadFactor`, prepares `numPositions` default positions on it (all identical), and sets +// up the passed in incentive records such that they emit on the pool. It also sets the largest authorized uptime to be `fullChargeDuration`. +// +// Returns the pool, expected position ids and the total liquidity created on the pool. +func (s *KeeperTestSuite) runFungifySetup(address sdk.AccAddress, numPositions int, fullChargeDuration time.Duration, poolSpreadFactor sdk.Dec, incentiveRecords []types.IncentiveRecord) (types.ConcentratedPoolExtension, []uint64, sdk.Dec) { + expectedPositionIds := make([]uint64, numPositions) + for i := 0; i < numPositions; i++ { + expectedPositionIds[i] = uint64(i + 1) + } + + s.TestAccs = apptesting.CreateRandomAccounts(5) + s.SetBlockTime(defaultBlockTime) + totalPositionsToCreate := sdk.NewInt(int64(numPositions)) + requiredBalances := sdk.NewCoins(sdk.NewCoin(ETH, DefaultAmt0.Mul(totalPositionsToCreate)), sdk.NewCoin(USDC, DefaultAmt1.Mul(totalPositionsToCreate))) + + // Set test authorized uptime params. + params := s.clk.GetParams(s.Ctx) + params.AuthorizedUptimes = []time.Duration{time.Nanosecond, fullChargeDuration} + s.clk.SetParams(s.Ctx, params) + + // Fund account + s.FundAcc(address, requiredBalances) + + // Create CL pool + pool := s.PrepareCustomConcentratedPool(s.TestAccs[0], ETH, USDC, DefaultTickSpacing, poolSpreadFactor) + + // Set incentives for pool to ensure accumulators work correctly + err := s.clk.SetMultipleIncentiveRecords(s.Ctx, incentiveRecords) + s.Require().NoError(err) + + // Set up fully charged positions + totalLiquidity := sdk.ZeroDec() + for i := 0; i < numPositions; i++ { + _, _, _, liquidityCreated, _, _, _, err := s.clk.CreatePosition(s.Ctx, defaultPoolId, address, DefaultCoins, sdk.ZeroInt(), sdk.ZeroInt(), DefaultLowerTick, DefaultUpperTick) + s.Require().NoError(err) + totalLiquidity = totalLiquidity.Add(liquidityCreated) + } + + return pool, expectedPositionIds, totalLiquidity +} + func (s *KeeperTestSuite) runMultipleAuthorizedUptimes(tests func()) { authorizedUptimesTested := [][]time.Duration{ DefaultAuthorizedUptimes, diff --git a/x/concentrated-liquidity/position_test.go b/x/concentrated-liquidity/position_test.go index 1ba4087b810..69cf9c094d5 100644 --- a/x/concentrated-liquidity/position_test.go +++ b/x/concentrated-liquidity/position_test.go @@ -15,7 +15,16 @@ import ( "github.com/osmosis-labs/osmosis/v16/x/concentrated-liquidity/types" ) -var DefaultIncentiveRecords = []types.IncentiveRecord{incentiveRecordOne, incentiveRecordTwo, incentiveRecordThree, incentiveRecordFour} +const ( + DefaultFungifyNumPositions = 3 + DefaultFungifyFullChargeDuration = 24 * time.Hour +) + +var ( + DefaultIncentiveRecords = []types.IncentiveRecord{incentiveRecordOne, incentiveRecordTwo, incentiveRecordThree, incentiveRecordFour} + DefaultBlockTime = time.Unix(1, 1).UTC() + DefaultSpreadFactor = sdk.NewDecWithPrec(2, 3) +) // AssertPositionsDoNotExist checks that the positions with the given IDs do not exist on uptime accumulators. func (s *KeeperTestSuite) AssertPositionsDoNotExist(positionIds []uint64) { @@ -1288,54 +1297,15 @@ func (s *KeeperTestSuite) TestFungifyChargedPositions_SwapAndClaimSpreadRewards( // Init suite for the test. s.SetupTest() - const ( - numPositions = 3 - testFullChargeDuration = time.Hour * 24 - swapAmount = 1_000_000 - ) + const swapAmount = 1_000_000 + var defaultAddress = s.TestAccs[0] - var ( - defaultAddress = s.TestAccs[0] - defaultBlockTime = time.Unix(1, 1).UTC() - spreadFactor = sdk.NewDecWithPrec(2, 3) - ) - - expectedPositionIds := make([]uint64, numPositions) - for i := 0; i < numPositions; i++ { - expectedPositionIds[i] = uint64(i + 1) - } - - s.TestAccs = apptesting.CreateRandomAccounts(5) - s.Ctx = s.Ctx.WithBlockTime(defaultBlockTime) - totalPositionsToCreate := sdk.NewInt(int64(numPositions)) - requiredBalances := sdk.NewCoins(sdk.NewCoin(ETH, DefaultAmt0.Mul(totalPositionsToCreate)), sdk.NewCoin(USDC, DefaultAmt1.Mul(totalPositionsToCreate))) - - // 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 account - s.FundAcc(defaultAddress, requiredBalances) - - // Create CL pool - s.PrepareCustomConcentratedPool(s.TestAccs[0], ETH, USDC, DefaultTickSpacing, spreadFactor) - - // Set incentives for pool to ensure accumulators work correctly - err := s.App.ConcentratedLiquidityKeeper.SetMultipleIncentiveRecords(s.Ctx, DefaultIncentiveRecords) - s.Require().NoError(err) - - // Set up fully charged positions - totalLiquidity := sdk.ZeroDec() - for i := 0; i < numPositions; i++ { - _, _, _, liquidityCreated, _, _, _, err := s.App.ConcentratedLiquidityKeeper.CreatePosition(s.Ctx, defaultPoolId, defaultAddress, DefaultCoins, sdk.ZeroInt(), sdk.ZeroInt(), DefaultLowerTick, DefaultUpperTick) - s.Require().NoError(err) - totalLiquidity = totalLiquidity.Add(liquidityCreated) - } + // Set up pool, positions, and incentive records + _, expectedPositionIds, totalLiquidity := s.runFungifySetup(defaultAddress, DefaultFungifyNumPositions, DefaultFungifyFullChargeDuration, DefaultSpreadFactor, DefaultIncentiveRecords) // Perform a swap to earn spread rewards swapAmountIn := sdk.NewCoin(ETH, sdk.NewInt(swapAmount)) - expectedSpreadReward := swapAmountIn.Amount.ToDec().Mul(spreadFactor) + expectedSpreadReward := swapAmountIn.Amount.ToDec().Mul(DefaultSpreadFactor) // We run expected spread rewards through a cycle of divison and multiplication by liquidity to capture appropriate rounding behavior. // Note that we truncate the int at the end since it is not possible to have a decimal spread reward amount collected (the QuoTruncate // and MulTruncates are much smaller operations that round down for values past the 18th decimal place). @@ -1344,34 +1314,34 @@ func (s *KeeperTestSuite) TestFungifyChargedPositions_SwapAndClaimSpreadRewards( s.swapAndTrackXTimesInARow(defaultPoolId, swapAmountIn, USDC, types.MinSpotPrice, 1) // Increase block time by the fully charged duration - s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(testFullChargeDuration)) + s.AddBlockTime(DefaultFungifyFullChargeDuration) // First run non mutative validation and check results - newPositionId, err := s.App.ConcentratedLiquidityKeeper.FungifyChargedPosition(s.Ctx, defaultAddress, expectedPositionIds) + newPositionId, err := s.clk.FungifyChargedPosition(s.Ctx, defaultAddress, expectedPositionIds) s.Require().NoError(err) // Claim spread rewards - collected, err := s.App.ConcentratedLiquidityKeeper.CollectSpreadRewards(s.Ctx, defaultAddress, newPositionId) + collected, err := s.clk.CollectSpreadRewards(s.Ctx, defaultAddress, newPositionId) s.Require().NoError(err) // Validate that the correct spread reward amount was collected. s.Require().Equal(expectedSpreadRewardTruncated, collected.AmountOf(swapAmountIn.Denom)) // Check that cannot claim again. - collected, err = s.App.ConcentratedLiquidityKeeper.CollectSpreadRewards(s.Ctx, defaultAddress, newPositionId) + collected, err = s.clk.CollectSpreadRewards(s.Ctx, defaultAddress, newPositionId) s.Require().NoError(err) s.Require().Equal(sdk.Coins(nil), collected) - spreadRewardAccum, err := s.App.ConcentratedLiquidityKeeper.GetSpreadRewardAccumulator(s.Ctx, defaultPoolId) + spreadRewardAccum, err := s.clk.GetSpreadRewardAccumulator(s.Ctx, defaultPoolId) s.Require().NoError(err) // Check that cannot claim old positions for _, oldPositionId := range expectedPositionIds { - collected, err = s.App.ConcentratedLiquidityKeeper.CollectSpreadRewards(s.Ctx, defaultAddress, oldPositionId) + collected, err = s.clk.CollectSpreadRewards(s.Ctx, defaultAddress, oldPositionId) s.Require().Error(err) s.Require().Equal(sdk.Coins{}, collected) - hasPosition := s.App.ConcentratedLiquidityKeeper.HasPosition(s.Ctx, oldPositionId) + hasPosition := s.clk.HasPosition(s.Ctx, oldPositionId) s.Require().False(hasPosition) hasSpreadRewardPositionTracker, err := spreadRewardAccum.HasPosition(types.KeySpreadRewardPositionAccumulator(oldPositionId)) @@ -1383,48 +1353,8 @@ func (s *KeeperTestSuite) TestFungifyChargedPositions_SwapAndClaimSpreadRewards( func (s *KeeperTestSuite) TestFungifyChargedPositions_ClaimIncentives() { // Init suite for the test. s.SetupTest() + var defaultAddress = s.TestAccs[0] - const ( - numPositions = 3 - testFullChargeDuration = 24 * time.Hour - ) - - var ( - defaultAddress = s.TestAccs[0] - defaultBlockTime = time.Unix(1, 1).UTC() - spreadFactor = sdk.NewDecWithPrec(2, 3) - ) - - expectedPositionIds := make([]uint64, numPositions) - for i := 0; i < numPositions; i++ { - expectedPositionIds[i] = uint64(i + 1) - } - - s.TestAccs = apptesting.CreateRandomAccounts(5) - s.Ctx = s.Ctx.WithBlockTime(defaultBlockTime) - totalPositionsToCreate := sdk.NewInt(int64(numPositions)) - requiredBalances := sdk.NewCoins(sdk.NewCoin(ETH, DefaultAmt0.Mul(totalPositionsToCreate)), sdk.NewCoin(USDC, DefaultAmt1.Mul(totalPositionsToCreate))) - - // 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 - s.FundAcc(defaultAddress, requiredBalances) - - // Create CL pool - pool := s.PrepareCustomConcentratedPool(s.TestAccs[0], ETH, USDC, DefaultTickSpacing, spreadFactor) - - // an error of 1 for each position - roundingError := int64(numPositions) - roundingTolerance := osmomath.ErrTolerance{ - AdditiveTolerance: sdk.NewDec(roundingError), - RoundingDir: osmomath.RoundDown, - } - expectedAmount := sdk.NewInt(60 * 60 * 24) // 1 day in seconds * 1 per second - - s.FundAcc(pool.GetIncentivesAddress(), sdk.NewCoins(sdk.NewCoin(USDC, expectedAmount))) // Set incentives for pool to ensure accumulators work correctly testIncentiveRecord := types.IncentiveRecord{ PoolId: 1, @@ -1437,45 +1367,47 @@ func (s *KeeperTestSuite) TestFungifyChargedPositions_ClaimIncentives() { }, MinUptime: time.Nanosecond, } - err := s.App.ConcentratedLiquidityKeeper.SetMultipleIncentiveRecords(s.Ctx, []types.IncentiveRecord{testIncentiveRecord}) - s.Require().NoError(err) - // Set up fully charged positions - totalLiquidity := sdk.ZeroDec() - for i := 0; i < numPositions; i++ { - _, _, _, liquidityCreated, _, _, _, err := s.App.ConcentratedLiquidityKeeper.CreatePosition(s.Ctx, defaultPoolId, defaultAddress, DefaultCoins, sdk.ZeroInt(), sdk.ZeroInt(), DefaultLowerTick, DefaultUpperTick) - s.Require().NoError(err) - totalLiquidity = totalLiquidity.Add(liquidityCreated) + // Set up pool, positions, and incentive records + pool, expectedPositionIds, _ := s.runFungifySetup(defaultAddress, DefaultFungifyNumPositions, DefaultFungifyFullChargeDuration, DefaultSpreadFactor, []types.IncentiveRecord{testIncentiveRecord}) + + // an error of 1 for each position + roundingError := int64(DefaultFungifyNumPositions) + roundingTolerance := osmomath.ErrTolerance{ + AdditiveTolerance: sdk.NewDec(roundingError), + RoundingDir: osmomath.RoundDown, } + expectedAmount := sdk.NewInt(60 * 60 * 24) // 1 day in seconds * 1 per second + s.FundAcc(pool.GetIncentivesAddress(), sdk.NewCoins(sdk.NewCoin(USDC, expectedAmount))) // Increase block time by the fully charged duration - s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(testFullChargeDuration)) + s.Ctx = s.Ctx.WithBlockTime(s.Ctx.BlockTime().Add(DefaultFungifyFullChargeDuration)) // sync accumulators // We use cache context to update uptime accumulators for estimating claimable incentives // prior to running fungify. However, we do not want the mutations made in test setup to have // impact on the system under test because it (fungify) must update the uptime accumulators itself. cacheCtx, _ := s.Ctx.CacheContext() - err = s.App.ConcentratedLiquidityKeeper.UpdateUptimeAccumulatorsToNow(cacheCtx, pool.GetId()) + err := s.clk.UpdateUptimeAccumulatorsToNow(cacheCtx, pool.GetId()) s.Require().NoError(err) claimableIncentives := sdk.NewCoins() - for i := 0; i < numPositions; i++ { - positionIncentices, forfeitedIncentives, err := s.App.ConcentratedLiquidityKeeper.GetClaimableIncentives(cacheCtx, uint64(i+1)) + for i := 0; i < DefaultFungifyNumPositions; i++ { + positionIncentives, forfeitedIncentives, err := s.clk.GetClaimableIncentives(cacheCtx, uint64(i+1)) s.Require().NoError(err) s.Require().Equal(sdk.Coins(nil), forfeitedIncentives) - claimableIncentives = claimableIncentives.Add(positionIncentices...) + claimableIncentives = claimableIncentives.Add(positionIncentives...) } actualClaimedAmount := claimableIncentives.AmountOf(USDC) s.Require().Equal(0, roundingTolerance.Compare(expectedAmount, actualClaimedAmount), "expected: %s, got: %s", expectedAmount, actualClaimedAmount) // System under test - newPositionId, err := s.App.ConcentratedLiquidityKeeper.FungifyChargedPosition(s.Ctx, defaultAddress, expectedPositionIds) + newPositionId, err := s.clk.FungifyChargedPosition(s.Ctx, defaultAddress, expectedPositionIds) s.Require().NoError(err) // Claim incentives. - collected, _, err := s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, newPositionId) + collected, _, err := s.clk.CollectIncentives(s.Ctx, defaultAddress, newPositionId) s.Require().NoError(err) // Validate that the correct incentives amount was collected. @@ -1484,18 +1416,234 @@ func (s *KeeperTestSuite) TestFungifyChargedPositions_ClaimIncentives() { s.Require().Equal(0, roundingTolerance.Compare(expectedAmount, actualClaimedAmount), "expected: %s, got: %s", expectedAmount, actualClaimedAmount) // Check that cannot claim again. - collected, _, err = s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, newPositionId) + collected, _, err = s.clk.CollectIncentives(s.Ctx, defaultAddress, newPositionId) s.Require().NoError(err) s.Require().Equal(sdk.Coins(nil), collected) // Check that cannot claim old positions - for i := 0; i < numPositions; i++ { - collected, _, err = s.App.ConcentratedLiquidityKeeper.CollectIncentives(s.Ctx, defaultAddress, uint64(i+1)) + for i := 0; i < DefaultFungifyNumPositions; i++ { + collected, _, err = s.clk.CollectIncentives(s.Ctx, defaultAddress, uint64(i+1)) s.Require().Error(err) s.Require().Equal(sdk.Coins{}, collected) } } +// TestFunctionalFungifyChargedPositions is a functional test that covers more complex scenarios related to fee/incentive claiming +// in the context of fungified positions. +// +// Testing strategy: +// 1. Create a pool with 6 positions in groups of 2 such that each group of 2 is adjacent to each other +// 2. Swap out all the USDC in the pool to generate spread rewards +// 3. Emit incentives (to left two positions) +// 4. Collect all spread rewards and incentives on cached ctx and make assertions +// 5. Fungify each set of positions +// 6. Collect all spread rewards and incentives on and make assertions +func (s *KeeperTestSuite) TestFunctionalFungifyChargedPositions() { + s.SetupTest() + s.Ctx = s.Ctx.WithBlockTime(defaultBlockTime) + roundingErrorCoins := sdk.NewCoins(sdk.NewCoin(ETH, roundingError), sdk.NewCoin(USDC, roundingError)) + + // 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, + } + + // --- Set up positions --- + + // Create the relevant positions with these acccounts such that the left, middle, and right positions + // are exactly adjacent to each other in terms of tick ranges. + defaultPositionWidth := DefaultUpperTick - DefaultLowerTick + + // middleAddress refers to the owner of the position in the middle of the three we create in this test + middleAddress := s.TestAccs[0] + + // Set up pool, default incentive records, and a single default position + pool, middlePositionIds, _ := s.runFungifySetup(middleAddress, 2, DefaultFungifyFullChargeDuration, DefaultSpreadFactor, []types.IncentiveRecord{testIncentiveRecord}) + + // Create two new addresses to hold a position to the left and right of the one we created above + testAccs := apptesting.CreateRandomAccounts(2) + leftAddress := testAccs[0] + rightAddress := testAccs[1] + + // We add dust amounts to each account since rounding makes `SetupPosition` occasionally error (one set of + // rounding error per position created) + // TODO: update `SetupPosition` to accommodate this internally + for _, acc := range testAccs { + s.FundAcc(acc, roundingErrorCoins.Add(roundingErrorCoins...)) + } + + // Set up left positions + leftPositionLowerTick := DefaultLowerTick - defaultPositionWidth + leftPositionUpperTick := DefaultLowerTick + _, leftOne := s.SetupPosition(pool.GetId(), leftAddress, DefaultCoins, leftPositionLowerTick, leftPositionUpperTick) + _, leftTwo := s.SetupPosition(pool.GetId(), leftAddress, DefaultCoins, leftPositionLowerTick, leftPositionUpperTick) + + // Set up right positions + rightPositionLowerTick := DefaultUpperTick + rightPositionUpperTick := DefaultUpperTick + defaultPositionWidth + _, rightOne := s.SetupPosition(pool.GetId(), rightAddress, DefaultCoins, rightPositionLowerTick, rightPositionUpperTick) + _, rightTwo := s.SetupPosition(pool.GetId(), rightAddress, DefaultCoins, rightPositionLowerTick, rightPositionUpperTick) + + // --- Set up large swap --- + + // Calculate input and output amounts for swap based on pool liquidity + pool, err := s.clk.GetPoolById(s.Ctx, pool.GetId()) + s.Require().NoError(err) + poolLiquidity := s.App.BankKeeper.GetAllBalances(s.Ctx, pool.GetAddress()) + usdcSupply := poolLiquidity.FilterDenoms([]string{USDC})[0] + usdcSupply = sdk.NewCoin(USDC, usdcSupply.Amount.Sub(sdk.NewInt(1))) + ethFunded := sdk.NewCoins(sdk.NewCoin(ETH, poolLiquidity.AmountOf(ETH).MulRaw(2))) + + // --- Execute large swap --- + + s.TestAccs = apptesting.CreateRandomAccounts(5) + s.FundAcc(s.TestAccs[4], ethFunded) + coinIn, _, _, _, _, err := s.clk.SwapInAmtGivenOut(s.Ctx, s.TestAccs[4], pool, usdcSupply, ETH, DefaultSpreadFactor, types.MinSpotPrice) + s.Require().NoError(err) + + // --- Set up expected spread rewards and incentives --- + + // Set up expected spread rewards + expectedTotalSpreadReward := coinIn.Amount.ToDec().Mul(DefaultSpreadFactor).Ceil().TruncateInt() + expectedTotalSpreadRewardCoins := sdk.NewCoins(sdk.NewCoin(coinIn.Denom, expectedTotalSpreadReward)) + + // Set up expected incentives + expectedIncentivesAmount := sdk.NewInt(int64(DefaultFungifyFullChargeDuration.Seconds())) + expectedIncentivesCoins := sdk.NewCoins(sdk.NewCoin(USDC, expectedIncentivesAmount)) + s.FundAcc(pool.GetIncentivesAddress(), expectedIncentivesCoins) + + // --- Emit incentives --- + + // Increase block time by the fully charged duration + // Note: claiming incentives should already trigger update incentives accumulators + s.AddBlockTime(DefaultFungifyFullChargeDuration) + + // --- Assertions on non-fungified positions --- + + // We operate and claim on cached context so we can compare against behavior with fungified positions + cacheCtx, _ := s.Ctx.CacheContext() + allPositionIds := []uint64{leftOne, leftTwo, middlePositionIds[0], middlePositionIds[1], rightOne, rightTwo} + positionOwners := []sdk.AccAddress{leftAddress, leftAddress, middleAddress, middleAddress, rightAddress, rightAddress} + + // Set up trackers for individual and total collected rewards + collectedSpreadRewardsMap := make(map[uint64]sdk.Coins, len(allPositionIds)) + collectedIncentivesMap := make(map[uint64]sdk.Coins, len(allPositionIds)) + totalCollectedSpread := sdk.NewCoins() + totalCollectedIncentives := sdk.NewCoins() + + for i, id := range allPositionIds { + // Collect spread rewards and incentives on cached context + collectedSpread, err := s.clk.CollectSpreadRewards(cacheCtx, positionOwners[i], id) + s.Require().NoError(err) + collectedIncentives, forfeited, err := s.clk.CollectIncentives(cacheCtx, positionOwners[i], id) + s.Require().NoError(err) + s.Require().True(forfeited.Empty()) + + // Ensure positions that aren't touched don't collect any spread rewards or incentives + if id == rightOne || id == rightTwo { + s.Require().True(collectedSpread.Empty()) + s.Require().True(collectedIncentives.Empty()) + } + + // Middle positions collect no incentives either since we emit after the swap + if id == middlePositionIds[0] || id == middlePositionIds[1] { + s.Require().True(collectedIncentives.Empty()) + } + + // Track total amounts + collectedSpreadRewardsMap[id] = collectedSpread + collectedIncentivesMap[id] = collectedIncentives + totalCollectedSpread = totalCollectedSpread.Add(collectedSpread...) + totalCollectedIncentives = totalCollectedIncentives.Add(collectedIncentives...) + } + + // Ensure that identical positions collected the same amounts + s.Require().Equal(collectedSpreadRewardsMap[leftOne], collectedSpreadRewardsMap[leftTwo]) + s.Require().Equal(collectedSpreadRewardsMap[middlePositionIds[0]], collectedSpreadRewardsMap[middlePositionIds[1]]) + s.Require().Equal(collectedSpreadRewardsMap[rightOne], collectedSpreadRewardsMap[rightTwo]) + + s.Require().Equal(collectedIncentivesMap[leftOne], collectedIncentivesMap[leftTwo]) + s.Require().Equal(collectedIncentivesMap[middlePositionIds[0]], collectedIncentivesMap[middlePositionIds[1]]) + s.Require().Equal(collectedIncentivesMap[rightOne], collectedIncentivesMap[rightTwo]) + + // Sanity check that majority of spread rewards went to the positions that provided majority of liquidity + s.Require().True(collectedSpreadRewardsMap[leftOne].IsAllGT(collectedSpreadRewardsMap[middlePositionIds[0]])) + + // Ensure that the total spread rewards collected is correct + roundingTolerance := osmomath.ErrTolerance{ + AdditiveTolerance: sdk.NewDec(int64(len(allPositionIds))), + RoundingDir: osmomath.RoundDown, + } + for _, spreadRewardCoin := range expectedTotalSpreadRewardCoins { + denom := spreadRewardCoin.Denom + s.Require().Equal(0, roundingTolerance.Compare(expectedTotalSpreadRewardCoins.AmountOf(denom), totalCollectedSpread.AmountOf(denom))) + } + + // Ensure that the total incentives collected is correct + for _, incentiveCoin := range expectedIncentivesCoins { + denom := incentiveCoin.Denom + s.Require().Equal(0, roundingTolerance.Compare(expectedIncentivesCoins.AmountOf(denom), totalCollectedIncentives.AmountOf(denom)), "expected: %s, got: %s", expectedIncentivesCoins.AmountOf(denom), totalCollectedIncentives.AmountOf(denom)) + } + + // --- System under test: Fungify positions --- + + fungifiedLeft, err := s.clk.FungifyChargedPosition(s.Ctx, leftAddress, []uint64{leftOne, leftTwo}) + s.Require().NoError(err) + fungifiedMiddle, err := s.clk.FungifyChargedPosition(s.Ctx, middleAddress, middlePositionIds) + s.Require().NoError(err) + fungifiedRight, err := s.clk.FungifyChargedPosition(s.Ctx, rightAddress, []uint64{rightOne, rightTwo}) + + // --- Spread reward assertions on fungified positions --- + + // Set up variables to represent loss due to truncation since expected values + // are derived from individual position claims (each of which truncate) + truncatedETHCoins := sdk.NewCoins(sdk.NewCoin(ETH, roundingError)) + truncatedUSDCCoins := sdk.NewCoins(sdk.NewCoin(USDC, roundingError)) + + // Left position spread reward assertion + fungifiedLeftSpread, err := s.clk.CollectSpreadRewards(s.Ctx, leftAddress, fungifiedLeft) + s.Require().NoError(err) + s.Require().Equal(collectedSpreadRewardsMap[leftOne].Add(collectedSpreadRewardsMap[leftTwo]...).Add(truncatedETHCoins...), fungifiedLeftSpread) + + // Middle position spread reward assertion + fungifiedMiddleSpread, err := s.clk.CollectSpreadRewards(s.Ctx, middleAddress, fungifiedMiddle) + s.Require().NoError(err) + s.Require().Equal(collectedSpreadRewardsMap[middlePositionIds[0]].Add(collectedSpreadRewardsMap[middlePositionIds[1]]...).Add(truncatedETHCoins...), fungifiedMiddleSpread) + + // Right position spread reward assertion + fungifiedRightSpread, err := s.clk.CollectSpreadRewards(s.Ctx, rightAddress, fungifiedRight) + s.Require().NoError(err) + s.Require().True(fungifiedRightSpread.Empty()) + + // --- Incentive assertions on fungified positions --- + + // Left position incentives assertion + fungifiedLeftIncentives, forfeited, err := s.clk.CollectIncentives(s.Ctx, leftAddress, fungifiedLeft) + s.Require().NoError(err) + s.Require().True(forfeited.Empty()) + s.Require().Equal(collectedIncentivesMap[leftOne].Add(collectedIncentivesMap[leftTwo]...).Add(truncatedUSDCCoins...), fungifiedLeftIncentives) + + // Middle position incentives assertion + fungifiedMiddleIncentives, forfeited, err := s.clk.CollectIncentives(s.Ctx, middleAddress, fungifiedMiddle) + s.Require().NoError(err) + s.Require().True(forfeited.Empty()) + s.Require().True(fungifiedMiddleIncentives.Empty()) + + // Right position incentives assertion + fungifiedRightIncentives, forfeited, err := s.clk.CollectIncentives(s.Ctx, rightAddress, fungifiedRight) + s.Require().NoError(err) + s.Require().True(forfeited.Empty()) + s.Require().True(fungifiedRightIncentives.Empty()) +} + func (s *KeeperTestSuite) TestCreateFullRangePosition() { var ( positionId uint64