Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix/test/docs: negative interval accumulation with spread rewards #5869

Merged
merged 8 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### State Breaking

* [#5532](https://github.com/osmosis-labs/osmosis/pull/5532) fix: Fix x/tokenfactory genesis import denoms reset x/bank existing denom metadata
* [#5869](https://github.com/osmosis-labs/osmosis/pull/5869) fix negative interval accumulation with spread rewards
* [#5883](https://github.com/osmosis-labs/osmosis/pull/5883) feat: Uninitialize empty ticks
* [#5874](https://github.com/osmosis-labs/osmosis/pull/5874) Remove Partial Migration from superfluid migration to CL
* [#5901](https://github.com/osmosis-labs/osmosis/pull/5901) Adding support for CW pools in ProtoRev
Expand Down
12 changes: 12 additions & 0 deletions app/apptesting/test_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ func (s *KeeperTestHelper) SetupTestForInitGenesis() {
s.hasUsedAbci = true
}

// RunTestCaseWithoutStateUpdates runs the testcase as a callback with the given name.
// Does not persist any state changes. This is useful when test suite uses common state setup
// but desures each test case to be run in isolation.
func (s *KeeperTestHelper) RunTestCaseWithoutStateUpdates(name string, cb func(t *testing.T)) {
originalCtx := s.Ctx
s.Ctx, _ = s.Ctx.CacheContext()

s.T().Run(name, cb)

s.Ctx = originalCtx
}

func (s *KeeperTestHelper) SetEpochStartTime() {
epochsKeeper := s.App.EpochsKeeper

Expand Down
6 changes: 1 addition & 5 deletions osmoutils/accum/accum.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,10 @@ func (accum *AccumulatorObject) NewPosition(name string, numShareUnits sdk.Dec,
// It sets the position's accumulator to the given value of intervalAccumulationPerShare.
// This is useful for when the accumulation happens at a sub-range of the full accumulator
// rewards range. For example, a concentrated liquidity narrow range position.
// All intervalAccumulationPerShare DecCoin values must be non-negative.
// intervalAccumulationPerShare DecCoin values are allowed to be negative.
// The position is initialized with empty unclaimed rewards
// If there is an existing position for the given address, it is overwritten.
func (accum *AccumulatorObject) NewPositionIntervalAccumulation(name string, numShareUnits sdk.Dec, intervalAccumulationPerShare sdk.DecCoins, options *Options) error {
if intervalAccumulationPerShare.IsAnyNegative() {
return NegativeIntervalAccumulationPerShareError{intervalAccumulationPerShare}
}

if err := options.validate(); err != nil {
return err
}
Expand Down
8 changes: 6 additions & 2 deletions osmoutils/accum/accum_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,16 @@ func (suite *AccumTestSuite) TestNewPositionIntervalAccumulation() {
},
options: &emptyPositionOptions,
},
"negative acc value - error": {
"negative acc value - no error": {
accObject: defaultAccObject,
name: testAddressOne,
numShareUnits: positionOne.NumShares,
intervalAccumulationPerShare: defaultAccObject.GetValue().MulDec(sdk.NewDec(-1)),
expectedError: accumPackage.NegativeIntervalAccumulationPerShareError{defaultAccObject.GetValue().MulDec(sdk.NewDec(-1))},
expectedPosition: accumPackage.Record{
NumShares: positionOne.NumShares,
AccumValuePerShare: defaultAccObject.GetValue().MulDec(sdk.NewDec(-1)),
UnclaimedRewardsTotal: emptyCoins,
},
},
}

Expand Down
640 changes: 0 additions & 640 deletions tests/cl-genesis-positions/go.sum

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions x/concentrated-liquidity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,78 @@ func (k Keeper) collectSpreadRewards(

This returns the amount of spread rewards collected by the user.

## Interval Accumulation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great synthesis!


As mentioned in the previous sections, to collect spread rewards,
we utilize a rewards accumulator abstraction with an interval accumulation extension.

The accumulator abstraction can be summarized by the following bullet points:
- accumulator values are per-share
- per-pool-global ever-increasing accumulator
- each position takes a snapshot of the accumulator at the time of creation
- assume t' represents the time of claiming rewards and t represents the time of position's snapshot of
the global accumulator. Then, the total position's rewards are equal to
`(global accumulator at time t' - position snapshot at time t) * number of shares

Interval accumulation further extends this abstraction where positions only accumulate the rewards whenever they are active.
That is, the current tick is within the position's range.

To calculate that, we utilize tick accumulators. Each tick accumulator is updated whenever it is crossed. It can be thought
of as having the snapshot of the global accumulator when going in the opposite direction of the last traversal. For example, assume that
we are reasoning about the snapshot value of tick 100 when the current tick is 150. Then, we know that the value of the tick is 100
contains rewards accrued before crossing tick 100 when going from the left to the right.

Fundamentally, our base accumulator abstraction changes to the following:
- accumulator values are per-share (still the same)
- per-pool-global ever-increasing accumulator (still the same)
- each position takes a snapshot of the rewards accumulated while the position is active at the time of its creation (as defined by tick accumulator snapshots) (changed)
- assume t' represents the time of claiming rewards and t represents the time of the position's snapshot of the interval accumulation.
Then, the total position's rewards are equal to `(global accumulator at time t' - interval accumulation outside at time t' - interval accumulation inside at time t) * number of shares
Essentially, this gives us interval accumulation inside between times t and t' for each share of the position.

By convention, we initialize the tick snapshot to either:
a) 0 if the current tick is < tick
b) global accumulator value if the current tick is >= tick

This is done to ensure that the tick accumulator always contains the rewards accrued before crossing the tick from left to right.

Since tick accumulator snapshots are initialized at different times, their comparison is not meaningful.

By having tick snapshots at each edge of the position and a global value, we can compute how many rewards are accrued inside
the position. It accounts for all rewards accrued between position creation and the time of claiming rewards whenever the following
is true:
`lower tick <= current tick < upper tick``

For this reason, there are 3 ways to compute the rewards accrued inside the position:
1. current tick is below the position's range (current tick < lower tick)
- Then, we compute rewards as `lower tick snapshot - upper tick snapshot`
2. current tick is within the position's range (lower tick <= current tick < upper tick)
- Then, we compute rewards as `global accumulator - upper tick snapshot - lower tick snapshot`
3. current tick is above the position's range (current tick >= upper tick)
- Then, we compute rewards as `upper tick accumulator - lower tick accumulator`

### Negative Interval Accumulation Edge Case Behavior

Note, that if we initialize the lower tick after the upper tick is already initialized,
for example, by another position, this might lead to negative accumulation inside
the interval. This is only possible if the current tick is greater than the lower tick
that is being initialized.

The reason is that we initialize the lower tick accumulator to the global accumulator
if the current tick >= tick. As a result, when subtracting the lower tick snapshot from the upper,
the lower one is greater than or equal to the upper.

This is not an issue because it gets canceled out by the "interval accumulation outside at time t'" that is added to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the more important point here is that the accumulator value is still always strictly increasing, meaning that the diff between the values at t' and t are always positive.

This alone should be sufficient to make the feature sound, regardless of whether the initial negative value is ever "canceled out"

the "interval accumulation inside at time t" before being subtracted from the global accumulator at the time of claiming.

Perhaps even more importantly, as long as the _change_ in interval accumulation is tracked correctly, the initial value should not make a difference.

Interestingly, this edge case should not be possible in the other direction. That is, we cannot get negative
interval accumulation inside if the upper is initialized after the lower and the current tick is less than the upper tick.
The reason is that if the current tick is less than the tick we initialize, the snapshot becomes 0 by convention.
As a result, the subtraction from the global accumulator for computing interval accumulation never leads to a
negative value.

## Swaps

Swapping within a single tick works as the regular `xy = k` curve. For swaps
Expand Down
3 changes: 2 additions & 1 deletion x/concentrated-liquidity/incentives.go
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,8 @@ func updateAccumAndClaimRewards(accum *accum.AccumulatorObject, positionKey stri
// The position accumulator value must always equal to the growth inside at the time of last update.
// Since this is the time we update the accumulator, we must subtract the growth outside from the global accumulator value
// to get growth inside at the current block time.
currentGrowthInsideForPosition := accum.GetValue().Sub(growthOutside)
// Note: this is SafeSub because interval accumulation is allowed to be negative.
currentGrowthInsideForPosition, _ := accum.GetValue().SafeSub(growthOutside)
err := accum.SetPositionIntervalAccumulation(positionKey, currentGrowthInsideForPosition)
if err != nil {
return sdk.Coins{}, sdk.DecCoins{}, err
Expand Down
17 changes: 14 additions & 3 deletions x/concentrated-liquidity/invariant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import (
)

type ExpectedGlobalRewardValues struct {
TotalSpreadRewards sdk.Coins
TotalIncentives sdk.Coins
// By default, the global reward checks just ensure that rounding is done
// in the pools favor.
// The tolerance here ensures that it rounded in the pools favor by at
// _at most_ ExpectedAdditiveTolerance units.
ExpectedAdditiveTolerance sdk.Dec
TotalSpreadRewards sdk.Coins
TotalIncentives sdk.Coins
}

// assertGlobalInvariants asserts all available global invariants (i.e. invariants that should hold on all valid states).
Expand Down Expand Up @@ -102,12 +107,18 @@ func (s *KeeperTestSuite) assertTotalRewardsInvariant(expectedGlobalRewardValues
totalCollectedIncentives = totalCollectedIncentives.Add(collectedIncentives...)
}

additiveTolerance := sdk.Dec{}
if !expectedGlobalRewardValues.ExpectedAdditiveTolerance.IsNil() {
additiveTolerance = expectedGlobalRewardValues.ExpectedAdditiveTolerance
}
ValarDragon marked this conversation as resolved.
Show resolved Hide resolved

// For global invariant checks, we simply ensure that any rounding error was in the pool's favor.
// This is to allow for cases where we slightly overround, which would otherwise fail here.
// TODO: create ErrTolerance type that allows for additive OR multiplicative tolerance to allow for
// tightening this check further.
errTolerance := osmomath.ErrTolerance{
RoundingDir: osmomath.RoundDown,
AdditiveTolerance: additiveTolerance,
RoundingDir: osmomath.RoundDown,
}

// Assert total collected spread rewards and incentives equal to expected
Expand Down
Loading