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

feat: scaling factor for pool uptime accumulator to avoid truncation #7409

Merged
merged 17 commits into from
Feb 7, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### State Breaking

* [#7409](https://github.com/osmosis-labs/osmosis/pull/7409) Scaling factor for pool uptime accumulator to avoid truncation
* [#7181](https://github.com/osmosis-labs/osmosis/pull/7181) Improve errors for out of gas
* [#7376](https://github.com/osmosis-labs/osmosis/pull/7376) Add uptime validation logic for `NoLock` (CL) gauges and switch CL gauge to pool ID links to be duration-based
* [#7357](https://github.com/osmosis-labs/osmosis/pull/7357) Fix: Ensure rate limits are not applied to packets that aren't ics20s
Expand Down
8 changes: 8 additions & 0 deletions app/apptesting/test_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -696,3 +696,11 @@ func (s *KeeperTestHelper) SetupVolumeForPools(poolIDs []uint64, volumesForEachP
func (s *KeeperTestHelper) IncreaseVolumeForPools(poolIDs []uint64, volumesForEachPool []osmomath.Int) {
s.SetupVolumeForPools(poolIDs, volumesForEachPool, map[uint64]osmomath.Int{})
}

// RequireDecCoinsSlice compares two slices of DecCoins
func (s *KeeperTestHelper) RequireDecCoinsSlice(expected, actual []sdk.DecCoins) {
s.Require().Equal(len(expected), len(actual))
for i := range actual {
s.Require().Equal(expected[i].String(), actual[i].String())
}
}
19 changes: 14 additions & 5 deletions x/concentrated-liquidity/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ const (
)

var (
EmptyCoins = emptyCoins
HundredFooCoins = sdk.NewDecCoin("foo", osmomath.NewInt(100))
HundredBarCoins = sdk.NewDecCoin("bar", osmomath.NewInt(100))
TwoHundredFooCoins = sdk.NewDecCoin("foo", osmomath.NewInt(200))
TwoHundredBarCoins = sdk.NewDecCoin("bar", osmomath.NewInt(200))
EmptyCoins = emptyCoins
HundredFooCoins = sdk.NewDecCoin("foo", osmomath.NewInt(100))
HundredBarCoins = sdk.NewDecCoin("bar", osmomath.NewInt(100))
TwoHundredFooCoins = sdk.NewDecCoin("foo", osmomath.NewInt(200))
TwoHundredBarCoins = sdk.NewDecCoin("bar", osmomath.NewInt(200))
PerUnitLiqScalingFactor = perUnitLiqScalingFactor
)

func (k Keeper) SetPool(ctx sdk.Context, pool types.ConcentratedPoolExtension) error {
Expand Down Expand Up @@ -344,3 +345,11 @@ func (k Keeper) CallPoolActionListener(ctx sdk.Context, msgBz []byte, poolId uin
func (k Keeper) GetPoolHookContract(ctx sdk.Context, poolId uint64, actionPrefix string) string {
return k.getPoolHookContract(ctx, poolId, actionPrefix)
}

func ScaleUpTotalEmittedAmount(totalEmittedAmount osmomath.Dec) (scaledTotalEmittedAmount osmomath.Dec, err error) {
return scaleUpTotalEmittedAmount(totalEmittedAmount)
}

func ComputeTotalIncentivesToEmit(timeElapsedSeconds osmomath.Dec, emissionRate osmomath.Dec) (totalEmittedAmount osmomath.Dec, err error) {
return computeTotalIncentivesToEmit(timeElapsedSeconds, emissionRate)
}
98 changes: 94 additions & 4 deletions x/concentrated-liquidity/incentives.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ import (
"github.com/osmosis-labs/osmosis/v22/x/concentrated-liquidity/types"
)

// We choose 10^27 to allow sufficient buffer before the accumulator starts getting truncated again.
// Internally, we multiply the number of seconds passed since the last liquidity update by the emission rate per second
// Then, we scale that value by 10^27 to avoid truncation to zero when dividing by the liquidity in the accumulator.
// We do not go for a higher scaling factor to allow for enough room before hitting the maximum integer value of 2^256
// in the intermediary multiplications.
//
// More analysis on the choice of scaling factor can be found here:
// https://hackmd.io/o3oqT8VhSPKAiqNl_mlxXQ
var perUnitLiqScalingFactor = osmomath.NewDec(1e15).MulMut(osmomath.NewDec(1e12))

// createUptimeAccumulators creates accumulator objects in store for each supported uptime for the given poolId.
// The accumulators are initialized with the default (zero) values.
func (k Keeper) createUptimeAccumulators(ctx sdk.Context, poolId uint64) error {
Expand Down Expand Up @@ -240,11 +250,27 @@ func calcAccruedIncentivesForAccum(ctx sdk.Context, accumUptime time.Duration, l
}

// Total amount emitted = time elapsed * emission
totalEmittedAmount := timeElapsed.Mul(incentiveRecordBody.EmissionRate)
totalEmittedAmount, err := computeTotalIncentivesToEmit(timeElapsed, incentiveRecordBody.EmissionRate)
if err != nil {
ctx.Logger().Info(types.IncentiveOverflowPlaceholderName, "pool_id", poolID, "incentive_id", incentiveRecord.IncentiveId, "time_elapsed", timeElapsed, "emission_rate", incentiveRecordBody.EmissionRate, "error", err.Error())
// Silently ignore the truncated incentive record to avoid halting the entire accumulator update.
// Continue to the next incentive record.
continue
}

// We scale up the remaining rewards to avoid truncation to zero
// when dividing by the liquidity in the accumulator.
scaledTotalEmittedAmount, err := scaleUpTotalEmittedAmount(totalEmittedAmount)
if err != nil {
ctx.Logger().Info(types.IncentiveOverflowPlaceholderName, "pool_id", poolID, "incentive_id", incentiveRecord.IncentiveId, "time_elapsed", timeElapsed, "emission_rate", incentiveRecordBody.EmissionRate, "error", err.Error())
// Silently ignore the truncated incentive record to avoid halting the entire accumulator update.
// Continue to the next incentive record.
continue
}

// Incentives to emit per unit of qualifying liquidity = total emitted / liquidityInAccum
// Note that we truncate to ensure we do not overdistribute incentives
incentivesPerLiquidity := totalEmittedAmount.QuoTruncate(liquidityInAccum)
incentivesPerLiquidity := scaledTotalEmittedAmount.QuoTruncate(liquidityInAccum)

// Emit telemetry for accumulator updates
emitAccumulatorUpdateTelemetry(ctx, types.IncentiveTruncationPlaceholderName, types.IncentiveEmissionPlaceholderName, incentivesPerLiquidity, totalEmittedAmount, poolID, liquidityInAccum)
Expand All @@ -266,7 +292,18 @@ func calcAccruedIncentivesForAccum(ctx sdk.Context, accumUptime time.Duration, l
} else {
// If there are not enough incentives remaining to be emitted, we emit the remaining rewards.
// When the returned records are set in state, all records with remaining rewards of zero will be cleared.
remainingIncentivesPerLiquidity := remainingRewards.QuoTruncate(liquidityInAccum)

// We scale up the remaining rewards to avoid truncation to zero
// when dividing by the liquidity in the accumulator.
remainingRewardsScaled, err := scaleUpTotalEmittedAmount(remainingRewards)
if err != nil {
ctx.Logger().Info(types.IncentiveOverflowPlaceholderName, "pool_id", poolID, "incentive_id", incentiveRecord.IncentiveId, "time_elapsed", timeElapsed, "emission_rate", incentiveRecordBody.EmissionRate, "error", err.Error())
// Silently ignore the truncated incentive record to avoid halting the entire accumulator update.
// Continue to the next incentive record.
continue
}
remainingIncentivesPerLiquidity := remainingRewardsScaled.QuoTruncateMut(liquidityInAccum)

emittedIncentivesPerLiquidity = sdk.NewDecCoinFromDec(incentiveRecordBody.RemainingCoin.Denom, remainingIncentivesPerLiquidity)

// Emit telemetry for accumulator updates
Expand All @@ -281,6 +318,48 @@ func calcAccruedIncentivesForAccum(ctx sdk.Context, accumUptime time.Duration, l
return incentivesToAddToCurAccum, copyPoolIncentiveRecords, nil
}

// scaleUpTotalEmittedAmount scales up the total emitted amount to avoid truncation to zero.
// Returns error if the total emitted amount is too high and causes overflow when applying scaling factor.
func scaleUpTotalEmittedAmount(totalEmittedAmount osmomath.Dec) (scaledTotalEmittedAmount osmomath.Dec, err error) {
defer func() {
r := recover()

if r != nil {
telemetry.IncrCounter(1, types.IncentiveOverflowPlaceholderName)
err = types.IncentiveScalingFactorOverflowError{
PanicMessage: fmt.Sprintf("%v", r),
}
}
}()

return totalEmittedAmount.MulTruncate(perUnitLiqScalingFactor), nil
}

// computeTotalIncentivesToEmit computes the total incentives to emit based on the time elapsed and emission rate.
// Returns error if timeElapsed or emissionRate are too high, causing overflow during multiplication.
func computeTotalIncentivesToEmit(timeElapsedSeconds osmomath.Dec, emissionRate osmomath.Dec) (totalEmittedAmount osmomath.Dec, err error) {
defer func() {
r := recover()

if r != nil {
telemetry.IncrCounter(1, types.IncentiveOverflowPlaceholderName)
err = types.IncentiveEmissionOvrflowError{
PanicMessage: fmt.Sprintf("%v", r),
}
}
}()

// This may panic if emissionRate is too high and causes overflow during multiplication
// We are unlikely to see an overflow due to too much time elapsing since
// 100 years in seconds is roughly
// 3.15576e9 * 100 = 3.15576e11
// 60 * 60 * 24 * 365 * 100 = 3153600000 seconds
// The bit decimal bit length is 2^256 which is arond 10^77
// However, it is possible for an attacker to try and create incentives with a very high emission rate
// consisting of cheap token in the USD denomination. This is why we have the panic recovery above.
return timeElapsedSeconds.MulTruncate(emissionRate), nil
}

// findUptimeIndex finds the uptime index for the passed in min uptime.
// Returns error if uptime index cannot be found.
func findUptimeIndex(uptime time.Duration) (int, error) {
Expand Down Expand Up @@ -705,11 +784,22 @@ func (k Keeper) prepareClaimAllIncentivesForPosition(ctx sdk.Context, positionId

// If the accumulator contains the position, claim the position's incentives.
if hasPosition {
collectedIncentivesForUptime, _, err := updateAccumAndClaimRewards(uptimeAccum, positionName, uptimeGrowthOutside[uptimeIndex])
collectedIncentivesForUptimeScaled, _, err := updateAccumAndClaimRewards(uptimeAccum, positionName, uptimeGrowthOutside[uptimeIndex])
if err != nil {
return sdk.Coins{}, sdk.Coins{}, err
}

// We scale the uptime per-unit of liquidity accumulator up to avoid truncation to zero.
// However, once we compute the total for the liquidity entitlement, we must scale it back down.
// We always truncate down in the pool's favor.
collectedIncentivesForUptime := sdk.NewCoins()
for _, incentiveCoin := range collectedIncentivesForUptimeScaled {
incentiveCoin.Amount = incentiveCoin.Amount.ToLegacyDec().QuoTruncateMut(perUnitLiqScalingFactor).TruncateInt()
AlpinYukseloglu marked this conversation as resolved.
Show resolved Hide resolved
if incentiveCoin.Amount.IsPositive() {
collectedIncentivesForUptime = append(collectedIncentivesForUptime, incentiveCoin)
}
Comment on lines +798 to +800
Copy link
Member

Choose a reason for hiding this comment

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

Is this to handle the case is which, even after scaling, we still truncate to zero?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah. It doesn't make sense to collect a bunch of zero coins so decided to simply skip them

}

if positionAge < supportedUptimes[uptimeIndex] {
// If the age of the position is less than the current uptime we are iterating through, then the position's
// incentives are forfeited to the community pool. The parent function does the actual bank send.
Expand Down
Loading