Skip to content

Commit

Permalink
feat: scaling factor for pool uptime accumulator to avoid truncation (#…
Browse files Browse the repository at this point in the history
…7409)

* feat: scaling factor for pool uptime accumulator

* changelog

* updates

* updates

* handle overflows

* Apply suggestions from code review

* updates

* Apply suggestions from code review

* fix test and clean up

* spelling

* future proof multiplication overflow

* clean up

* comment

* lint

* rename
  • Loading branch information
p0mvn committed Feb 8, 2024
1 parent ca6928c commit a9aa89f
Show file tree
Hide file tree
Showing 9 changed files with 304 additions and 57 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v23.0.0

### State Breaking

* [#7409](https://github.com/osmosis-labs/osmosis/pull/7409) Scaling factor for pool uptime accumulator to avoid truncation

## v22.0.5

### Logging
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()
if incentiveCoin.Amount.IsPositive() {
collectedIncentivesForUptime = append(collectedIncentivesForUptime, incentiveCoin)
}
}

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

0 comments on commit a9aa89f

Please sign in to comment.