From 6f193c7f2ad3277d2caf7303ac534cbe9f036f1f Mon Sep 17 00:00:00 2001 From: Ruaridh Date: Thu, 22 Jul 2021 13:53:18 +0100 Subject: [PATCH] Refactor incentive accumulators to be the same (#970) * add test for validate multi reward periods * tidy up: combine files * don't accumulate global indexes containing zeros Previously if the time since last block was 0, indexes were added containing 0s. Now leave them out. Missing is assumed to be 0. * move state independent test to types folder * clarify reward source concept to "source shares" - rename variables and update doc comments - extract method from swap accumulation * tidy up and expand swap accumulation unit tests * rename swap test file to match others * update swap pool id format in tests * refactor borrow accumulation, use new accumulator * refactor supply accumulation, use new accumulator * refactor delegator accumulation, use accumulator * refactor usdx accumulation, use new accumulator * fix types const * remove unsed methods * more usdx minting param validation. Protect against the rewards per second denom changing. It should always be "ukava". * add safety check in InitGenesis It prevents huge accumulations on the first block by limiting all previous accumulation times to be within one year of genesis * add todo for adding swp token distirbution info --- x/incentive/abci.go | 22 +- x/incentive/alias.go | 6 +- x/incentive/genesis.go | 33 ++ x/incentive/genesis_test.go | 108 ++++-- x/incentive/keeper/cdp_test.go | 3 +- x/incentive/keeper/keeper.go | 8 +- x/incentive/keeper/rewards_borrow.go | 110 +++--- .../keeper/rewards_borrow_accum_test.go | 316 ++++++++++++++++++ x/incentive/keeper/rewards_borrow_test.go | 15 +- x/incentive/keeper/rewards_delegator.go | 70 ++-- .../keeper/rewards_delegator_accum_test.go | 309 +++++++++++++++++ .../keeper/rewards_delegator_init_test.go | 31 +- .../keeper/rewards_delegator_sync_test.go | 16 +- x/incentive/keeper/rewards_delegator_test.go | 9 +- x/incentive/keeper/rewards_supply.go | 124 ++----- .../keeper/rewards_supply_accum_test.go | 316 ++++++++++++++++++ x/incentive/keeper/rewards_supply_test.go | 16 +- x/incentive/keeper/rewards_swap.go | 19 +- ...wap_test.go => rewards_swap_accum_test.go} | 239 +++++++------ x/incentive/keeper/rewards_swap_init_test.go | 8 +- x/incentive/keeper/rewards_swap_sync_test.go | 8 +- x/incentive/keeper/rewards_usdx.go | 67 ++-- x/incentive/keeper/rewards_usdx_accum_test.go | 234 +++++++++++++ x/incentive/keeper/rewards_usdx_test.go | 9 +- x/incentive/keeper/unit_test.go | 196 +++++++++++ x/incentive/spec/01_concepts.md | 10 +- x/incentive/spec/README.md | 2 +- x/incentive/testutil/builder.go | 19 +- x/incentive/types/accumulator.go | 32 +- x/incentive/types/accumulator_test.go | 98 ++++-- x/incentive/types/claims.go | 2 - x/incentive/types/keys.go | 2 - x/incentive/types/params.go | 38 ++- x/incentive/types/params_test.go | 131 +++++++- x/incentive/types/period.go | 11 - x/incentive/types/sdk.go | 5 + 36 files changed, 2117 insertions(+), 525 deletions(-) create mode 100644 x/incentive/keeper/rewards_borrow_accum_test.go create mode 100644 x/incentive/keeper/rewards_delegator_accum_test.go create mode 100644 x/incentive/keeper/rewards_supply_accum_test.go rename x/incentive/keeper/{rewards_swap_test.go => rewards_swap_accum_test.go} (55%) create mode 100644 x/incentive/keeper/rewards_usdx_accum_test.go delete mode 100644 x/incentive/types/period.go diff --git a/x/incentive/abci.go b/x/incentive/abci.go index 00a814d1..2d0c5015 100644 --- a/x/incentive/abci.go +++ b/x/incentive/abci.go @@ -8,30 +8,20 @@ import ( // BeginBlocker runs at the start of every block func BeginBlocker(ctx sdk.Context, k keeper.Keeper) { + params := k.GetParams(ctx) + for _, rp := range params.USDXMintingRewardPeriods { - err := k.AccumulateUSDXMintingRewards(ctx, rp) - if err != nil { - panic(err) - } + k.AccumulateUSDXMintingRewards(ctx, rp) } for _, rp := range params.HardSupplyRewardPeriods { - err := k.AccumulateHardSupplyRewards(ctx, rp) - if err != nil { - panic(err) - } + k.AccumulateHardSupplyRewards(ctx, rp) } for _, rp := range params.HardBorrowRewardPeriods { - err := k.AccumulateHardBorrowRewards(ctx, rp) - if err != nil { - panic(err) - } + k.AccumulateHardBorrowRewards(ctx, rp) } for _, rp := range params.DelegatorRewardPeriods { - err := k.AccumulateDelegatorRewards(ctx, rp) - if err != nil { - panic(err) - } + k.AccumulateDelegatorRewards(ctx, rp) } for _, rp := range params.SwapRewardPeriods { k.AccumulateSwapRewards(ctx, rp) diff --git a/x/incentive/alias.go b/x/incentive/alias.go index 8070c2bb..81716762 100644 --- a/x/incentive/alias.go +++ b/x/incentive/alias.go @@ -17,7 +17,6 @@ const ( AttributeKeyClaimedBy = types.AttributeKeyClaimedBy AttributeKeyRewardPeriod = types.AttributeKeyRewardPeriod AttributeValueCategory = types.AttributeValueCategory - BondDenom = types.BondDenom DefaultParamspace = types.DefaultParamspace DelegatorClaimType = types.DelegatorClaimType EventTypeClaim = types.EventTypeClaim @@ -49,7 +48,6 @@ const ( var ( // function aliases - CalculateTimeElapsed = keeper.CalculateTimeElapsed NewKeeper = keeper.NewKeeper NewQuerier = keeper.NewQuerier DefaultGenesisState = types.DefaultGenesisState @@ -73,6 +71,7 @@ var ( NewMsgClaimUSDXMintingRewardVVesting = types.NewMsgClaimUSDXMintingRewardVVesting NewMultiRewardIndex = types.NewMultiRewardIndex NewMultiRewardPeriod = types.NewMultiRewardPeriod + NewMultiRewardPeriodFromRewardPeriod = types.NewMultiRewardPeriodFromRewardPeriod NewMultiplier = types.NewMultiplier NewParams = types.NewParams NewPeriod = types.NewPeriod @@ -86,6 +85,7 @@ var ( RegisterCodec = types.RegisterCodec // variable aliases + BondDenom = types.BondDenom DefaultActive = types.DefaultActive DefaultClaimEnd = types.DefaultClaimEnd DefaultDelegatorClaims = types.DefaultDelegatorClaims @@ -111,7 +111,6 @@ var ( ErrNoClaimsFound = types.ErrNoClaimsFound ErrRewardPeriodNotFound = types.ErrRewardPeriodNotFound ErrZeroClaim = types.ErrZeroClaim - GovDenom = types.GovDenom HardBorrowRewardIndexesKeyPrefix = types.HardBorrowRewardIndexesKeyPrefix HardLiquidityClaimKeyPrefix = types.HardLiquidityClaimKeyPrefix HardSupplyRewardIndexesKeyPrefix = types.HardSupplyRewardIndexesKeyPrefix @@ -129,7 +128,6 @@ var ( PreviousHardSupplyRewardAccrualTimeKeyPrefix = types.PreviousHardSupplyRewardAccrualTimeKeyPrefix PreviousSwapRewardAccrualTimeKeyPrefix = types.PreviousSwapRewardAccrualTimeKeyPrefix PreviousUSDXMintingRewardAccrualTimeKeyPrefix = types.PreviousUSDXMintingRewardAccrualTimeKeyPrefix - PrincipalDenom = types.PrincipalDenom SwapClaimKeyPrefix = types.SwapClaimKeyPrefix SwapRewardIndexesKeyPrefix = types.SwapRewardIndexesKeyPrefix USDXMintingClaimKeyPrefix = types.USDXMintingClaimKeyPrefix diff --git a/x/incentive/genesis.go b/x/incentive/genesis.go index 69e5b6bf..0fe667f5 100644 --- a/x/incentive/genesis.go +++ b/x/incentive/genesis.go @@ -10,6 +10,12 @@ import ( "github.com/kava-labs/kava/x/incentive/types" ) +const year = 365 * 24 * time.Hour + +// EarliestValidAccumulationTime is how far behind the genesis time an accumulation time can be for it to be valid. +// It's a safety check to ensure rewards aren't accidentally accumulated for many years on the first block (eg since Jan 1970). +var EarliestValidAccumulationTime time.Duration = year + // InitGenesis initializes the store state from a genesis state. func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeeper, cdpKeeper types.CdpKeeper, gs types.GenesisState) { @@ -37,6 +43,9 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeep k.SetUSDXMintingClaim(ctx, claim) } for _, gat := range gs.USDXRewardState.AccumulationTimes { + if err := ValidateAccumulationTime(gat.PreviousAccumulationTime, ctx.BlockTime()); err != nil { + panic(err.Error()) + } k.SetPreviousUSDXMintingAccrualTime(ctx, gat.CollateralType, gat.PreviousAccumulationTime) } for _, mri := range gs.USDXRewardState.MultiRewardIndexes { @@ -52,12 +61,18 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeep k.SetHardLiquidityProviderClaim(ctx, claim) } for _, gat := range gs.HardSupplyRewardState.AccumulationTimes { + if err := ValidateAccumulationTime(gat.PreviousAccumulationTime, ctx.BlockTime()); err != nil { + panic(err.Error()) + } k.SetPreviousHardSupplyRewardAccrualTime(ctx, gat.CollateralType, gat.PreviousAccumulationTime) } for _, mri := range gs.HardSupplyRewardState.MultiRewardIndexes { k.SetHardSupplyRewardIndexes(ctx, mri.CollateralType, mri.RewardIndexes) } for _, gat := range gs.HardBorrowRewardState.AccumulationTimes { + if err := ValidateAccumulationTime(gat.PreviousAccumulationTime, ctx.BlockTime()); err != nil { + panic(err.Error()) + } k.SetPreviousHardBorrowRewardAccrualTime(ctx, gat.CollateralType, gat.PreviousAccumulationTime) } for _, mri := range gs.HardBorrowRewardState.MultiRewardIndexes { @@ -69,6 +84,9 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeep k.SetDelegatorClaim(ctx, claim) } for _, gat := range gs.DelegatorRewardState.AccumulationTimes { + if err := ValidateAccumulationTime(gat.PreviousAccumulationTime, ctx.BlockTime()); err != nil { + panic(err.Error()) + } k.SetPreviousDelegatorRewardAccrualTime(ctx, gat.CollateralType, gat.PreviousAccumulationTime) } for _, mri := range gs.DelegatorRewardState.MultiRewardIndexes { @@ -80,6 +98,9 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeep k.SetSwapClaim(ctx, claim) } for _, gat := range gs.SwapRewardState.AccumulationTimes { + if err := ValidateAccumulationTime(gat.PreviousAccumulationTime, ctx.BlockTime()); err != nil { + panic(err.Error()) + } k.SetSwapRewardAccrualTime(ctx, gat.CollateralType, gat.PreviousAccumulationTime) } for _, mri := range gs.SwapRewardState.MultiRewardIndexes { @@ -201,3 +222,15 @@ func getSwapGenesisRewardState(ctx sdk.Context, keeper keeper.Keeper) types.Gene return types.NewGenesisRewardState(ats, mris) } + +func ValidateAccumulationTime(previousAccumulationTime, genesisTime time.Time) error { + if previousAccumulationTime.Before(genesisTime.Add(-1 * EarliestValidAccumulationTime)) { + return fmt.Errorf( + "found accumulation time '%s' more than '%s' behind genesis time '%s'", + previousAccumulationTime, + EarliestValidAccumulationTime, + genesisTime, + ) + } + return nil +} diff --git a/x/incentive/genesis_test.go b/x/incentive/genesis_test.go index cbae40aa..e2036495 100644 --- a/x/incentive/genesis_test.go +++ b/x/incentive/genesis_test.go @@ -96,34 +96,6 @@ func (suite *GenesisTestSuite) SetupTest() { suite.ctx = ctx } -// Test to cover an bug where paid out claims would zero out rewards incorrectly, creating an invalid coins object. -// The invalid reward coins would fail the genesis state validation -func (suite *GenesisTestSuite) TestPaidOutClaimsPassValidateGenesis() { - hardHandler := hard.NewHandler(suite.app.GetHardKeeper()) - _, err := hardHandler(suite.ctx, hard.NewMsgDeposit(suite.addrs[0], cs(c("bnb", 100_000_000)))) - suite.Require().NoError(err) - - suite.app.EndBlocker(suite.ctx, abci.RequestEndBlock{}) - suite.ctx = suite.ctx.WithBlockTime(suite.genesisTime.Add(1 * 10 * time.Second)) - suite.app.BeginBlocker(suite.ctx, abci.RequestBeginBlock{}) - - suite.app.EndBlocker(suite.ctx, abci.RequestEndBlock{}) - suite.ctx = suite.ctx.WithBlockTime(suite.genesisTime.Add(2 * 10 * time.Second)) - suite.app.BeginBlocker(suite.ctx, abci.RequestBeginBlock{}) - - _, err = hardHandler(suite.ctx, hard.NewMsgWithdraw(suite.addrs[0], cs(c("bnb", 100_000_000)))) - suite.Require().NoError(err) - - incentiveHandler := incentive.NewHandler(suite.keeper) - _, err = incentiveHandler(suite.ctx, incentive.NewMsgClaimHardReward(suite.addrs[0], string(incentive.Large), nil)) - suite.Require().NoError(err) - - genState := incentive.ExportGenesis(suite.ctx, suite.keeper) - suite.Require().NoError( - genState.Validate(), - ) -} - func (suite *GenesisTestSuite) TestExportedGenesisMatchesImported() { genesisTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) genesisState := incentive.NewGenesisState( @@ -219,7 +191,7 @@ func (suite *GenesisTestSuite) TestExportedGenesisMatchesImported() { ) tApp := app.NewTestApp() - ctx := tApp.NewContext(true, abci.Header{Height: 1}) + ctx := tApp.NewContext(true, abci.Header{Height: 0, Time: genesisTime}) // Incentive init genesis reads from the cdp keeper to check params are ok. So it needs to be initialized first. // Then the cdp keeper reads from pricefeed keeper to check its params are ok. So it also need initialization. @@ -235,6 +207,84 @@ func (suite *GenesisTestSuite) TestExportedGenesisMatchesImported() { suite.Equal(genesisState, exportedGenesisState) } +func (suite *GenesisTestSuite) TestInitGenesisPanicsWhenAccumulationTimesToLongAgo() { + genesisTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + invalidRewardState := incentive.NewGenesisRewardState( + incentive.AccumulationTimes{ + incentive.NewAccumulationTime("bnb", genesisTime.Add(-23*incentive.EarliestValidAccumulationTime).Add(-time.Nanosecond)), + }, + incentive.MultiRewardIndexes{}, + ) + minimalParams := incentive.Params{ + ClaimEnd: genesisTime.Add(5 * oneYear), + } + + testCases := []struct { + genesisState incentive.GenesisState + }{ + { + incentive.GenesisState{ + Params: minimalParams, + USDXRewardState: invalidRewardState, + }, + }, + { + incentive.GenesisState{ + Params: minimalParams, + HardSupplyRewardState: invalidRewardState, + }, + }, + { + incentive.GenesisState{ + Params: minimalParams, + HardBorrowRewardState: invalidRewardState, + }, + }, + { + incentive.GenesisState{ + Params: minimalParams, + DelegatorRewardState: invalidRewardState, + }, + }, + { + incentive.GenesisState{ + Params: minimalParams, + SwapRewardState: invalidRewardState, + }, + }, + } + + for _, tc := range testCases { + + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{Height: 0, Time: genesisTime}) + + // Incentive init genesis reads from the cdp keeper to check params are ok. So it needs to be initialized first. + // Then the cdp keeper reads from pricefeed keeper to check its params are ok. So it also need initialization. + tApp.InitializeFromGenesisStates( + NewCDPGenStateMulti(), + NewPricefeedGenStateMultiFromTime(genesisTime), + ) + + suite.PanicsWithValue( + "found accumulation time '1975-01-06 23:59:59.999999999 +0000 UTC' more than '8760h0m0s' behind genesis time '1998-01-01 00:00:00 +0000 UTC'", + func() { + incentive.InitGenesis(ctx, tApp.GetIncentiveKeeper(), tApp.GetSupplyKeeper(), tApp.GetCDPKeeper(), tc.genesisState) + }, + ) + } +} + +func (suite *GenesisTestSuite) TestValidateAccumulationTime() { + genTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + + err := incentive.ValidateAccumulationTime( + genTime.Add(-incentive.EarliestValidAccumulationTime).Add(-time.Nanosecond), + genTime, + ) + suite.Error(err) +} + func TestGenesisTestSuite(t *testing.T) { suite.Run(t, new(GenesisTestSuite)) } diff --git a/x/incentive/keeper/cdp_test.go b/x/incentive/keeper/cdp_test.go index ff7453c9..83f256b7 100644 --- a/x/incentive/keeper/cdp_test.go +++ b/x/incentive/keeper/cdp_test.go @@ -29,7 +29,8 @@ func TestRiskyCDPsAccumulateRewards(t *testing.T) { WithSimpleUSDXRewardPeriod(collateralType, rewardsPerSecond) tApp := app.NewTestApp() - tApp.InitializeFromGenesisStates( + tApp.InitializeFromGenesisStatesWithTime( + genesisTime, authBuilder.BuildMarshalled(), NewPricefeedGenStateMultiFromTime(genesisTime), NewCDPGenStateMulti(), diff --git a/x/incentive/keeper/keeper.go b/x/incentive/keeper/keeper.go index ee28cbaf..a8d04522 100644 --- a/x/incentive/keeper/keeper.go +++ b/x/incentive/keeper/keeper.go @@ -319,7 +319,7 @@ func (k Keeper) GetHardSupplyRewardIndexes(ctx sdk.Context, denom string) (types store := prefix.NewStore(ctx.KVStore(k.key), types.HardSupplyRewardIndexesKeyPrefix) bz := store.Get([]byte(denom)) if bz == nil { - return types.RewardIndexes{}, false + return nil, false } var rewardIndexes types.RewardIndexes k.cdc.MustUnmarshalBinaryBare(bz, &rewardIndexes) @@ -366,7 +366,7 @@ func (k Keeper) GetHardBorrowRewardIndexes(ctx sdk.Context, denom string) (types store := prefix.NewStore(ctx.KVStore(k.key), types.HardBorrowRewardIndexesKeyPrefix) bz := store.Get([]byte(denom)) if bz == nil { - return types.RewardIndexes{}, false + return nil, false } var rewardIndexes types.RewardIndexes k.cdc.MustUnmarshalBinaryBare(bz, &rewardIndexes) @@ -406,7 +406,7 @@ func (k Keeper) GetDelegatorRewardIndexes(ctx sdk.Context, denom string) (types. store := prefix.NewStore(ctx.KVStore(k.key), types.DelegatorRewardIndexesKeyPrefix) bz := store.Get([]byte(denom)) if bz == nil { - return types.RewardIndexes{}, false + return nil, false } var rewardIndexes types.RewardIndexes k.cdc.MustUnmarshalBinaryBare(bz, &rewardIndexes) @@ -511,7 +511,7 @@ func (k Keeper) GetSwapRewardIndexes(ctx sdk.Context, poolID string) (types.Rewa store := prefix.NewStore(ctx.KVStore(k.key), types.SwapRewardIndexesKeyPrefix) bz := store.Get([]byte(poolID)) if bz == nil { - return types.RewardIndexes{}, false + return nil, false } var rewardIndexes types.RewardIndexes k.cdc.MustUnmarshalBinaryBare(bz, &rewardIndexes) diff --git a/x/incentive/keeper/rewards_borrow.go b/x/incentive/keeper/rewards_borrow.go index 7ca2ba37..61b8d768 100644 --- a/x/incentive/keeper/rewards_borrow.go +++ b/x/incentive/keeper/rewards_borrow.go @@ -10,70 +10,52 @@ import ( "github.com/kava-labs/kava/x/incentive/types" ) -// AccumulateHardBorrowRewards updates the rewards accumulated for the input reward period -func (k Keeper) AccumulateHardBorrowRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) error { +// AccumulateHardBorrowRewards calculates new rewards to distribute this block and updates the global indexes to reflect this. +// The provided rewardPeriod must be valid to avoid panics in calculating time durations. +func (k Keeper) AccumulateHardBorrowRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) { + previousAccrualTime, found := k.GetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType) if !found { - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - timeElapsed := CalculateTimeElapsed(rewardPeriod.Start, rewardPeriod.End, ctx.BlockTime(), previousAccrualTime) - if timeElapsed.IsZero() { - return nil - } - if rewardPeriod.RewardsPerSecond.IsZero() { - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + previousAccrualTime = ctx.BlockTime() } - totalBorrowedCoins, foundTotalBorrowedCoins := k.hardKeeper.GetBorrowedCoins(ctx) - if !foundTotalBorrowedCoins { - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - - totalBorrowed := totalBorrowedCoins.AmountOf(rewardPeriod.CollateralType).ToDec() - if totalBorrowed.IsZero() { - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - - previousRewardIndexes, found := k.GetHardBorrowRewardIndexes(ctx, rewardPeriod.CollateralType) + indexes, found := k.GetHardBorrowRewardIndexes(ctx, rewardPeriod.CollateralType) if !found { - for _, rewardCoin := range rewardPeriod.RewardsPerSecond { - rewardIndex := types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - previousRewardIndexes = append(previousRewardIndexes, rewardIndex) - } - k.SetHardBorrowRewardIndexes(ctx, rewardPeriod.CollateralType, previousRewardIndexes) + indexes = types.RewardIndexes{} } - hardFactor, found := k.hardKeeper.GetBorrowInterestFactor(ctx, rewardPeriod.CollateralType) + + acc := types.NewAccumulator(previousAccrualTime, indexes) + + totalSource := k.getHardBorrowTotalSourceShares(ctx, rewardPeriod.CollateralType) + + acc.Accumulate(rewardPeriod, totalSource, ctx.BlockTime()) + + k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, acc.PreviousAccumulationTime) + if len(acc.Indexes) > 0 { + // the store panics when setting empty or nil indexes + k.SetHardBorrowRewardIndexes(ctx, rewardPeriod.CollateralType, acc.Indexes) + } +} + +// getHardBorrowTotalSourceShares fetches the sum of all source shares for a borrow reward. +// In the case of hard borrow, this is the total borrowed divided by the borrow interest factor. +// This give the "pre interest" value of the total borrowed. +func (k Keeper) getHardBorrowTotalSourceShares(ctx sdk.Context, denom string) sdk.Dec { + totalBorrowedCoins, found := k.hardKeeper.GetBorrowedCoins(ctx) if !found { - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + // assume no coins have been borrowed + totalBorrowedCoins = sdk.NewCoins() + } + totalBorrowed := totalBorrowedCoins.AmountOf(denom) + + interestFactor, found := k.hardKeeper.GetBorrowInterestFactor(ctx, denom) + if !found { + // assume nothing has been borrowed so the factor starts at it's default value + interestFactor = sdk.OneDec() } - newRewardIndexes := previousRewardIndexes - for _, rewardCoin := range rewardPeriod.RewardsPerSecond { - newRewards := rewardCoin.Amount.ToDec().Mul(timeElapsed.ToDec()) - previousRewardIndex, found := previousRewardIndexes.GetRewardIndex(rewardCoin.Denom) - if !found { - previousRewardIndex = types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - } - - // Calculate new reward factor and update reward index - rewardFactor := newRewards.Mul(hardFactor).Quo(totalBorrowed) - newRewardFactorValue := previousRewardIndex.RewardFactor.Add(rewardFactor) - newRewardIndex := types.NewRewardIndex(rewardCoin.Denom, newRewardFactorValue) - i, found := newRewardIndexes.GetFactorIndex(rewardCoin.Denom) - if found { - newRewardIndexes[i] = newRewardIndex - } else { - newRewardIndexes = append(newRewardIndexes, newRewardIndex) - } - } - k.SetHardBorrowRewardIndexes(ctx, rewardPeriod.CollateralType, newRewardIndexes) - k.SetPreviousHardBorrowRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + // return borrowed/factor to get the "pre interest" value of the current total borrowed + return totalBorrowed.ToDec().Quo(interestFactor) } // InitializeHardBorrowReward initializes the borrow-side of a hard liquidity provider claim @@ -172,14 +154,14 @@ func (k Keeper) UpdateHardBorrowIndexDenoms(ctx sdk.Context, borrow hardtypes.Bo k.SetHardLiquidityProviderClaim(ctx, claim) } -// CalculateRewards computes how much rewards should have accrued to a source (eg a user's hard borrowed btc amount) +// CalculateRewards computes how much rewards should have accrued to a reward source (eg a user's hard borrowed btc amount) // between two index values. // -// oldIndex is normally the index stored on a claim, newIndex the current global value, and rewardSource a hard borrowed/supplied amount. +// oldIndex is normally the index stored on a claim, newIndex the current global value, and sourceShares a hard borrowed/supplied amount. // -// Returns an error if newIndexes does not contain all CollateralTypes from oldIndexes, or if any value of oldIndex.RewardFactor > newIndex.RewardFactor. +// It returns an error if newIndexes does not contain all CollateralTypes from oldIndexes, or if any value of oldIndex.RewardFactor > newIndex.RewardFactor. // This should never happen, as it would mean that a global reward index has decreased in value, or that a global reward index has been deleted from state. -func (k Keeper) CalculateRewards(oldIndexes, newIndexes types.RewardIndexes, rewardSource sdk.Dec) (sdk.Coins, error) { +func (k Keeper) CalculateRewards(oldIndexes, newIndexes types.RewardIndexes, sourceShares sdk.Dec) (sdk.Coins, error) { // check for missing CollateralType's for _, oldIndex := range oldIndexes { if newIndex, found := newIndexes.Get(oldIndex.CollateralType); !found { @@ -193,7 +175,7 @@ func (k Keeper) CalculateRewards(oldIndexes, newIndexes types.RewardIndexes, rew oldFactor = sdk.ZeroDec() } - rewardAmount, err := k.CalculateSingleReward(oldFactor, newIndex.RewardFactor, rewardSource) + rewardAmount, err := k.CalculateSingleReward(oldFactor, newIndex.RewardFactor, sourceShares) if err != nil { return nil, err } @@ -205,18 +187,18 @@ func (k Keeper) CalculateRewards(oldIndexes, newIndexes types.RewardIndexes, rew return reward, nil } -// CalculateSingleReward computes how much rewards should have accrued to a source (eg a user's btcb-a cdp principal) +// CalculateSingleReward computes how much rewards should have accrued to a reward source (eg a user's btcb-a cdp principal) // between two index values. // -// oldIndex is normally the index stored on a claim, newIndex the current global value, and rewardSource a cdp principal amount. +// oldIndex is normally the index stored on a claim, newIndex the current global value, and sourceShares a cdp principal amount. // // Returns an error if oldIndex > newIndex. This should never happen, as it would mean that a global reward index has decreased in value, // or that a global reward index has been deleted from state. -func (k Keeper) CalculateSingleReward(oldIndex, newIndex, rewardSource sdk.Dec) (sdk.Int, error) { +func (k Keeper) CalculateSingleReward(oldIndex, newIndex, sourceShares sdk.Dec) (sdk.Int, error) { increase := newIndex.Sub(oldIndex) if increase.IsNegative() { return sdk.Int{}, sdkerrors.Wrapf(types.ErrDecreasingRewardFactor, "old: %v, new: %v", oldIndex, newIndex) } - reward := increase.Mul(rewardSource).RoundInt() + reward := increase.Mul(sourceShares).RoundInt() return reward, nil } diff --git a/x/incentive/keeper/rewards_borrow_accum_test.go b/x/incentive/keeper/rewards_borrow_accum_test.go new file mode 100644 index 00000000..969f5b3d --- /dev/null +++ b/x/incentive/keeper/rewards_borrow_accum_test.go @@ -0,0 +1,316 @@ +package keeper_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/kava-labs/kava/x/incentive/types" +) + +type AccumulateBorrowRewardsTests struct { + unitTester +} + +func (suite *AccumulateBorrowRewardsTests) storedTimeEquals(denom string, expected time.Time) { + storedTime, found := suite.keeper.GetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom) + suite.True(found) + suite.Equal(expected, storedTime) +} + +func (suite *AccumulateBorrowRewardsTests) storedIndexesEqual(denom string, expected types.RewardIndexes) { + storedIndexes, found := suite.keeper.GetHardBorrowRewardIndexes(suite.ctx, denom) + suite.Equal(found, expected != nil) + suite.Equal(expected, storedIndexes) +} + +func TestAccumulateBorrowRewards(t *testing.T) { + suite.Run(t, new(AccumulateBorrowRewardsTests)) +} + +func (suite *AccumulateBorrowRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreased() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalBorrow(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + suite.storeGlobalBorrowIndexes(types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + }) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + newAccrualTime := previousAccrualTime.Add(1 * time.Hour) + suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, newAccrualTime) + suite.storedIndexesEqual(denom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("7.22"), + }, + { + CollateralType: "ukava", + RewardFactor: d("3.64"), + }, + }) +} + +func (suite *AccumulateBorrowRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalBorrow(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalBorrowIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, previousAccrualTime) + expected, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expected) +} + +func (suite *AccumulateBorrowRewardsTests) TestNoAccumulationWhenSourceSharesAreZero() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper() // zero total borrows + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalBorrowIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(7 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, firstAccrualTime) + expected, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expected) +} + +func (suite *AccumulateBorrowRewardsTests) TestStateAddedWhenStateDoesNotExist() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalBorrow(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // After the first accumulation only the current block time should be stored. + // The indexes will be empty as no time has passed since the previous block because it didn't exist. + suite.storedTimeEquals(denom, firstAccrualTime) + suite.storedIndexesEqual(denom, nil) + + secondAccrualTime := firstAccrualTime.Add(10 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(secondAccrualTime) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // After the second accumulation both current block time and indexes should be stored. + suite.storedTimeEquals(denom, secondAccrualTime) + suite.storedIndexesEqual(denom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.01"), + }, + }) +} + +func (suite *AccumulateBorrowRewardsTests) TestNoPanicWhenStateDoesNotExist() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper() + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(), + ) + + accrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(accrualTime) + + // Accumulate with no source shares and no rewards per second will result in no increment to the indexes. + // No increment and no previous indexes stored, results in an updated of nil. Setting this in the state panics. + // Check there is no panic. + suite.NotPanics(func() { + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + }) + + suite.storedTimeEquals(denom, accrualTime) + suite.storedIndexesEqual(denom, nil) +} + +func (suite *AccumulateBorrowRewardsTests) TestNoAccumulationWhenBeforeStartTime() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalBorrow(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalBorrowIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(10 * time.Second) + + period := types.NewMultiRewardPeriod( + true, + denom, + firstAccrualTime.Add(time.Nanosecond), // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + + // The accrual time should be updated, but the indexes unchanged + suite.storedTimeEquals(denom, firstAccrualTime) + expectedIndexes, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expectedIndexes) +} + +func (suite *AccumulateBorrowRewardsTests) TestPanicWhenCurrentTimeLessThanPrevious() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalBorrow(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardBorrowRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := time.Time{} + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Time{}, // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.Panics(func() { + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, period) + }) +} diff --git a/x/incentive/keeper/rewards_borrow_test.go b/x/incentive/keeper/rewards_borrow_test.go index e15bba18..42981881 100644 --- a/x/incentive/keeper/rewards_borrow_test.go +++ b/x/incentive/keeper/rewards_borrow_test.go @@ -185,8 +185,7 @@ func (suite *BorrowRewardsTestSuite) TestAccumulateHardBorrowRewards() { // Accumulate hard borrow rewards for the deposit denom multiRewardPeriod, found := suite.keeper.GetHardBorrowRewardPeriods(runCtx, tc.args.borrow.Denom) suite.Require().True(found) - err = suite.keeper.AccumulateHardBorrowRewards(runCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardBorrowRewards(runCtx, multiRewardPeriod) // Check that each expected reward index matches the current stored reward index for the denom globalRewardIndexes, found := suite.keeper.GetHardBorrowRewardIndexes(runCtx, tc.args.borrow.Denom) @@ -555,8 +554,7 @@ func (suite *BorrowRewardsTestSuite) TestSynchronizeHardBorrowReward() { // Accumulate hard borrow-side rewards multiRewardPeriod, found := suite.keeper.GetHardBorrowRewardPeriods(blockCtx, tc.args.borrow.Denom) if found { - err := suite.keeper.AccumulateHardBorrowRewards(blockCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardBorrowRewards(blockCtx, multiRewardPeriod) } } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) @@ -666,15 +664,13 @@ func (suite *BorrowRewardsTestSuite) TestSynchronizeHardBorrowReward() { // But new borrow denoms don't have their PreviousHardBorrowRewardAccrualTime set yet, // so we need to call the accumulation method once to set the initial reward accrual time if tc.args.borrow.Denom != tc.args.incentiveBorrowRewardDenom { - err = suite.keeper.AccumulateHardBorrowRewards(suite.ctx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, multiRewardPeriod) } // Now we can jump forward in time and accumulate rewards updatedBlockTime = previousBlockTime.Add(time.Duration(int(time.Second) * tc.args.updatedTimeDuration)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) - err = suite.keeper.AccumulateHardBorrowRewards(suite.ctx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardBorrowRewards(suite.ctx, multiRewardPeriod) // After we've accumulated, run synchronize borrow, found = suite.hardKeeper.GetBorrow(suite.ctx, userAddr) @@ -963,8 +959,7 @@ func (suite *BorrowRewardsTestSuite) TestSimulateHardBorrowRewardSynchronization // Accumulate hard borrow-side rewards multiRewardPeriod, found := suite.keeper.GetHardBorrowRewardPeriods(blockCtx, tc.args.borrow.Denom) suite.Require().True(found) - err := suite.keeper.AccumulateHardBorrowRewards(blockCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardBorrowRewards(blockCtx, multiRewardPeriod) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) diff --git a/x/incentive/keeper/rewards_delegator.go b/x/incentive/keeper/rewards_delegator.go index e83e3fa2..c712ed4b 100644 --- a/x/incentive/keeper/rewards_delegator.go +++ b/x/incentive/keeper/rewards_delegator.go @@ -8,59 +8,39 @@ import ( "github.com/kava-labs/kava/x/incentive/types" ) -// AccumulateDelegatorRewards updates the rewards accumulated for the input reward period -func (k Keeper) AccumulateDelegatorRewards(ctx sdk.Context, rewardPeriods types.MultiRewardPeriod) error { - previousAccrualTime, found := k.GetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriods.CollateralType) +// AccumulateDelegatorRewards calculates new rewards to distribute this block and updates the global indexes to reflect this. +// The provided rewardPeriod must be valid to avoid panics in calculating time durations. +func (k Keeper) AccumulateDelegatorRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) { + + previousAccrualTime, found := k.GetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriod.CollateralType) if !found { - k.SetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriods.CollateralType, ctx.BlockTime()) - return nil - } - timeElapsed := CalculateTimeElapsed(rewardPeriods.Start, rewardPeriods.End, ctx.BlockTime(), previousAccrualTime) - if timeElapsed.IsZero() { - return nil - } - if rewardPeriods.RewardsPerSecond.IsZero() { - k.SetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriods.CollateralType, ctx.BlockTime()) - return nil + previousAccrualTime = ctx.BlockTime() } - totalBonded := k.stakingKeeper.TotalBondedTokens(ctx).ToDec() - if totalBonded.IsZero() { - k.SetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriods.CollateralType, ctx.BlockTime()) - return nil - } - - previousRewardIndexes, found := k.GetDelegatorRewardIndexes(ctx, rewardPeriods.CollateralType) + indexes, found := k.GetDelegatorRewardIndexes(ctx, rewardPeriod.CollateralType) if !found { - for _, rewardCoin := range rewardPeriods.RewardsPerSecond { - rewardIndex := types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - previousRewardIndexes = append(previousRewardIndexes, rewardIndex) - } - k.SetDelegatorRewardIndexes(ctx, rewardPeriods.CollateralType, previousRewardIndexes) + indexes = types.RewardIndexes{} } - newRewardIndexes := previousRewardIndexes - for _, rewardCoin := range rewardPeriods.RewardsPerSecond { - newRewards := rewardCoin.Amount.ToDec().Mul(timeElapsed.ToDec()) - previousRewardIndex, found := previousRewardIndexes.GetRewardIndex(rewardCoin.Denom) - if !found { - previousRewardIndex = types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - } + acc := types.NewAccumulator(previousAccrualTime, indexes) - // Calculate new reward factor and update reward index - rewardFactor := newRewards.Quo(totalBonded) - newRewardFactorValue := previousRewardIndex.RewardFactor.Add(rewardFactor) - newRewardIndex := types.NewRewardIndex(rewardCoin.Denom, newRewardFactorValue) - i, found := newRewardIndexes.GetFactorIndex(rewardCoin.Denom) - if found { - newRewardIndexes[i] = newRewardIndex - } else { - newRewardIndexes = append(newRewardIndexes, newRewardIndex) - } + totalSource := k.getDelegatorTotalSourceShares(ctx, rewardPeriod.CollateralType) + + acc.Accumulate(rewardPeriod, totalSource, ctx.BlockTime()) + + k.SetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriod.CollateralType, acc.PreviousAccumulationTime) + if len(acc.Indexes) > 0 { + // the store panics when setting empty or nil indexes + k.SetDelegatorRewardIndexes(ctx, rewardPeriod.CollateralType, acc.Indexes) } - k.SetDelegatorRewardIndexes(ctx, rewardPeriods.CollateralType, newRewardIndexes) - k.SetPreviousDelegatorRewardAccrualTime(ctx, rewardPeriods.CollateralType, ctx.BlockTime()) - return nil +} + +// getDelegatorTotalSourceShares fetches the sum of all source shares for a delegator reward. +// In the case of delegation, this is the total tokens staked to bonded validators. +func (k Keeper) getDelegatorTotalSourceShares(ctx sdk.Context, denom string) sdk.Dec { + totalBonded := k.stakingKeeper.TotalBondedTokens(ctx) + + return totalBonded.ToDec() } // InitializeDelegatorReward initializes the reward index of a delegator claim diff --git a/x/incentive/keeper/rewards_delegator_accum_test.go b/x/incentive/keeper/rewards_delegator_accum_test.go new file mode 100644 index 00000000..831a7617 --- /dev/null +++ b/x/incentive/keeper/rewards_delegator_accum_test.go @@ -0,0 +1,309 @@ +package keeper_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/kava-labs/kava/x/incentive/types" +) + +type AccumulateDelegatorRewardsTests struct { + unitTester +} + +func (suite *AccumulateDelegatorRewardsTests) storedTimeEquals(denom string, expected time.Time) { + storedTime, found := suite.keeper.GetPreviousDelegatorRewardAccrualTime(suite.ctx, denom) + suite.True(found) + suite.Equal(expected, storedTime) +} + +func (suite *AccumulateDelegatorRewardsTests) storedIndexesEqual(denom string, expected types.RewardIndexes) { + storedIndexes, found := suite.keeper.GetDelegatorRewardIndexes(suite.ctx, denom) + suite.Equal(found, expected != nil) + suite.Equal(expected, storedIndexes) +} + +func TestAccumulateDelegatorRewards(t *testing.T) { + suite.Run(t, new(AccumulateDelegatorRewardsTests)) +} + +func (suite *AccumulateDelegatorRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreased() { + + stakingKeeper := newFakeStakingKeeper().addBondedTokens(1e6) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + suite.storeGlobalDelegatorIndexes(types.MultiRewardIndexes{ + { + CollateralType: types.BondDenom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + }) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousDelegatorRewardAccrualTime(suite.ctx, types.BondDenom, previousAccrualTime) + + newAccrualTime := previousAccrualTime.Add(1 * time.Hour) + suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(types.BondDenom, newAccrualTime) + suite.storedIndexesEqual(types.BondDenom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("7.22"), + }, + { + CollateralType: "ukava", + RewardFactor: d("3.64"), + }, + }) +} + +func (suite *AccumulateDelegatorRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { + + stakingKeeper := newFakeStakingKeeper().addBondedTokens(1e6) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: types.BondDenom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalDelegatorIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousDelegatorRewardAccrualTime(suite.ctx, types.BondDenom, previousAccrualTime) + + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(types.BondDenom, previousAccrualTime) + expected, f := previousIndexes.Get(types.BondDenom) + suite.True(f) + suite.storedIndexesEqual(types.BondDenom, expected) +} + +func (suite *AccumulateDelegatorRewardsTests) TestNoAccumulationWhenSourceSharesAreZero() { + + stakingKeeper := newFakeStakingKeeper() // zero total bonded + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: types.BondDenom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalDelegatorIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousDelegatorRewardAccrualTime(suite.ctx, types.BondDenom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(7 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(types.BondDenom, firstAccrualTime) + expected, f := previousIndexes.Get(types.BondDenom) + suite.True(f) + suite.storedIndexesEqual(types.BondDenom, expected) +} + +func (suite *AccumulateDelegatorRewardsTests) TestStateAddedWhenStateDoesNotExist() { + + stakingKeeper := newFakeStakingKeeper().addBondedTokens(1e6) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // After the first accumulation only the current block time should be stored. + // The indexes will be empty as no time has passed since the previous block because it didn't exist. + suite.storedTimeEquals(types.BondDenom, firstAccrualTime) + suite.storedIndexesEqual(types.BondDenom, nil) + + secondAccrualTime := firstAccrualTime.Add(10 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(secondAccrualTime) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // After the second accumulation both current block time and indexes should be stored. + suite.storedTimeEquals(types.BondDenom, secondAccrualTime) + suite.storedIndexesEqual(types.BondDenom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.01"), + }, + }) +} + +func (suite *AccumulateDelegatorRewardsTests) TestNoPanicWhenStateDoesNotExist() { + + stakingKeeper := newFakeStakingKeeper() + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(), + ) + + accrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(accrualTime) + + // Accumulate with no source shares and no rewards per second will result in no increment to the indexes. + // No increment and no previous indexes stored, results in an updated of nil. Setting this in the state panics. + // Check there is no panic. + suite.NotPanics(func() { + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + }) + + suite.storedTimeEquals(types.BondDenom, accrualTime) + suite.storedIndexesEqual(types.BondDenom, nil) +} + +func (suite *AccumulateDelegatorRewardsTests) TestNoAccumulationWhenBeforeStartTime() { + + stakingKeeper := newFakeStakingKeeper().addBondedTokens(1e6) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: types.BondDenom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalDelegatorIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousDelegatorRewardAccrualTime(suite.ctx, types.BondDenom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(10 * time.Second) + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + firstAccrualTime.Add(time.Nanosecond), // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + + // The accrual time should be updated, but the indexes unchanged + suite.storedTimeEquals(types.BondDenom, firstAccrualTime) + expectedIndexes, f := previousIndexes.Get(types.BondDenom) + suite.True(f) + suite.storedIndexesEqual(types.BondDenom, expectedIndexes) +} + +func (suite *AccumulateDelegatorRewardsTests) TestPanicWhenCurrentTimeLessThanPrevious() { + + stakingKeeper := newFakeStakingKeeper().addBondedTokens(1e6) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousDelegatorRewardAccrualTime(suite.ctx, types.BondDenom, previousAccrualTime) + + firstAccrualTime := time.Time{} + + period := types.NewMultiRewardPeriod( + true, + types.BondDenom, + time.Time{}, // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.Panics(func() { + suite.keeper.AccumulateDelegatorRewards(suite.ctx, period) + }) +} diff --git a/x/incentive/keeper/rewards_delegator_init_test.go b/x/incentive/keeper/rewards_delegator_init_test.go index 9f006178..2b3933fc 100644 --- a/x/incentive/keeper/rewards_delegator_init_test.go +++ b/x/incentive/keeper/rewards_delegator_init_test.go @@ -47,7 +47,7 @@ func (suite *InitializeDelegatorRewardTests) TestClaimIndexesAreSetWhenClaimDoes func (suite *InitializeDelegatorRewardTests) TestClaimIsSyncedAndIndexesAreSetWhenClaimDoesExist() { validatorAddress := arbitraryValidatorAddress() - sk := fakeStakingKeeper{ + sk := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{{ ValidatorAddress: validatorAddress, Shares: d("1000"), @@ -96,32 +96,3 @@ var arbitraryDelegatorRewardIndexes = types.MultiRewardIndexes{ }, ), } - -type fakeStakingKeeper struct { - delegations stakingtypes.Delegations - validators stakingtypes.Validators -} - -func (k fakeStakingKeeper) TotalBondedTokens(ctx sdk.Context) sdk.Int { - panic("unimplemented") -} -func (k fakeStakingKeeper) GetDelegatorDelegations(ctx sdk.Context, delegator sdk.AccAddress, maxRetrieve uint16) []stakingtypes.Delegation { - return k.delegations -} -func (k fakeStakingKeeper) GetValidator(ctx sdk.Context, addr sdk.ValAddress) (stakingtypes.Validator, bool) { - for _, val := range k.validators { - if val.GetOperator().Equals(addr) { - return val, true - } - } - return stakingtypes.Validator{}, false -} -func (k fakeStakingKeeper) GetValidatorDelegations(ctx sdk.Context, valAddr sdk.ValAddress) []stakingtypes.Delegation { - var delegations stakingtypes.Delegations - for _, d := range k.delegations { - if d.ValidatorAddress.Equals(valAddr) { - delegations = append(delegations, d) - } - } - return delegations -} diff --git a/x/incentive/keeper/rewards_delegator_sync_test.go b/x/incentive/keeper/rewards_delegator_sync_test.go index 6f6afe61..4b249b82 100644 --- a/x/incentive/keeper/rewards_delegator_sync_test.go +++ b/x/incentive/keeper/rewards_delegator_sync_test.go @@ -36,7 +36,7 @@ func (suite *SynchronizeDelegatorRewardTests) storeGlobalDelegatorFactor(multiRe func (suite *SynchronizeDelegatorRewardTests) TestClaimIndexesAreUnchangedWhenGlobalFactorUnchanged() { delegator := arbitraryAddress() - stakingKeeper := fakeStakingKeeper{} // use an empty staking keeper that returns no delegations + stakingKeeper := &fakeStakingKeeper{} // use an empty staking keeper that returns no delegations suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, stakingKeeper, nil) claim := types.DelegatorClaim{ @@ -58,7 +58,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestClaimIndexesAreUnchangedWhenGl func (suite *SynchronizeDelegatorRewardTests) TestClaimIndexesAreUpdatedWhenGlobalFactorIncreased() { delegator := arbitraryAddress() - suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, fakeStakingKeeper{}, nil) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, &fakeStakingKeeper{}, nil) claim := types.DelegatorClaim{ BaseMultiClaim: types.BaseMultiClaim{ @@ -85,7 +85,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestClaimIndexesAreUpdatedWhenGlob func (suite *SynchronizeDelegatorRewardTests) TestRewardIsUnchangedWhenGlobalFactorUnchanged() { delegator := arbitraryAddress() validatorAddress := arbitraryValidatorAddress() - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ { DelegatorAddress: delegator, @@ -130,7 +130,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestRewardIsUnchangedWhenGlobalFac func (suite *SynchronizeDelegatorRewardTests) TestRewardIsIncreasedWhenNewRewardAdded() { delegator := arbitraryAddress() validatorAddress := arbitraryValidatorAddress() - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ { DelegatorAddress: delegator, @@ -180,7 +180,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestRewardIsIncreasedWhenNewReward func (suite *SynchronizeDelegatorRewardTests) TestRewardIsIncreasedWhenGlobalFactorIncreased() { delegator := arbitraryAddress() validatorAddress := arbitraryValidatorAddress() - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ { DelegatorAddress: delegator, @@ -266,7 +266,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestGetDelegatedWhenValAddrIsNil() // when valAddr is nil, get total delegated to bonded validators delegator := arbitraryAddress() validatorAddresses := generateValidatorAddresses(4) - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ //bonded { @@ -309,7 +309,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestGetDelegatedWhenExcludingAVali // when valAddr is x, get total delegated to bonded validators excluding those to x delegator := arbitraryAddress() validatorAddresses := generateValidatorAddresses(4) - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ //bonded { @@ -352,7 +352,7 @@ func (suite *SynchronizeDelegatorRewardTests) TestGetDelegatedWhenIncludingAVali // when valAddr is x, get total delegated to bonded validators including those to x delegator := arbitraryAddress() validatorAddresses := generateValidatorAddresses(4) - stakingKeeper := fakeStakingKeeper{ + stakingKeeper := &fakeStakingKeeper{ delegations: stakingtypes.Delegations{ //bonded { diff --git a/x/incentive/keeper/rewards_delegator_test.go b/x/incentive/keeper/rewards_delegator_test.go index 00027d6a..6df0ccd9 100644 --- a/x/incentive/keeper/rewards_delegator_test.go +++ b/x/incentive/keeper/rewards_delegator_test.go @@ -148,8 +148,7 @@ func (suite *DelegatorRewardsTestSuite) TestAccumulateDelegatorRewards() { rewardPeriods, found := suite.keeper.GetDelegatorRewardPeriods(runCtx, tc.args.delegation.Denom) suite.Require().True(found) - err = suite.keeper.AccumulateDelegatorRewards(runCtx, rewardPeriods) - suite.Require().NoError(err) + suite.keeper.AccumulateDelegatorRewards(runCtx, rewardPeriods) rewardIndexes, _ := suite.keeper.GetDelegatorRewardIndexes(runCtx, tc.args.delegation.Denom) suite.Require().Equal(tc.args.expectedRewardIndexes, rewardIndexes) @@ -269,8 +268,7 @@ func (suite *DelegatorRewardsTestSuite) TestSynchronizeDelegatorReward() { rewardPeriods, found := suite.keeper.GetDelegatorRewardPeriods(blockCtx, tc.args.delegation.Denom) suite.Require().True(found) - err := suite.keeper.AccumulateDelegatorRewards(blockCtx, rewardPeriods) - suite.Require().NoError(err) + suite.keeper.AccumulateDelegatorRewards(blockCtx, rewardPeriods) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) @@ -385,8 +383,7 @@ func (suite *DelegatorRewardsTestSuite) TestSimulateDelegatorRewardSynchronizati // Accumulate delegator rewards rewardPeriods, found := suite.keeper.GetDelegatorRewardPeriods(blockCtx, tc.args.delegation.Denom) suite.Require().True(found) - err := suite.keeper.AccumulateDelegatorRewards(blockCtx, rewardPeriods) - suite.Require().NoError(err) + suite.keeper.AccumulateDelegatorRewards(blockCtx, rewardPeriods) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) diff --git a/x/incentive/keeper/rewards_supply.go b/x/incentive/keeper/rewards_supply.go index cfc33584..210a4d8e 100644 --- a/x/incentive/keeper/rewards_supply.go +++ b/x/incentive/keeper/rewards_supply.go @@ -2,8 +2,6 @@ package keeper import ( "fmt" - "math" - "time" sdk "github.com/cosmos/cosmos-sdk/types" @@ -11,70 +9,52 @@ import ( "github.com/kava-labs/kava/x/incentive/types" ) -// AccumulateHardSupplyRewards updates the rewards accumulated for the input reward period -func (k Keeper) AccumulateHardSupplyRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) error { +// AccumulateHardSupplyRewards calculates new rewards to distribute this block and updates the global indexes to reflect this. +// The provided rewardPeriod must be valid to avoid panics in calculating time durations. +func (k Keeper) AccumulateHardSupplyRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) { + previousAccrualTime, found := k.GetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType) if !found { - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - timeElapsed := CalculateTimeElapsed(rewardPeriod.Start, rewardPeriod.End, ctx.BlockTime(), previousAccrualTime) - if timeElapsed.IsZero() { - return nil - } - if rewardPeriod.RewardsPerSecond.IsZero() { - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + previousAccrualTime = ctx.BlockTime() } - totalSuppliedCoins, foundTotalSuppliedCoins := k.hardKeeper.GetSuppliedCoins(ctx) - if !foundTotalSuppliedCoins { - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - - totalSupplied := totalSuppliedCoins.AmountOf(rewardPeriod.CollateralType).ToDec() - if totalSupplied.IsZero() { - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - - previousRewardIndexes, found := k.GetHardSupplyRewardIndexes(ctx, rewardPeriod.CollateralType) + indexes, found := k.GetHardSupplyRewardIndexes(ctx, rewardPeriod.CollateralType) if !found { - for _, rewardCoin := range rewardPeriod.RewardsPerSecond { - rewardIndex := types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - previousRewardIndexes = append(previousRewardIndexes, rewardIndex) - } - k.SetHardSupplyRewardIndexes(ctx, rewardPeriod.CollateralType, previousRewardIndexes) + indexes = types.RewardIndexes{} } - hardFactor, found := k.hardKeeper.GetSupplyInterestFactor(ctx, rewardPeriod.CollateralType) + + acc := types.NewAccumulator(previousAccrualTime, indexes) + + totalSource := k.getHardSupplyTotalSourceShares(ctx, rewardPeriod.CollateralType) + + acc.Accumulate(rewardPeriod, totalSource, ctx.BlockTime()) + + k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, acc.PreviousAccumulationTime) + if len(acc.Indexes) > 0 { + // the store panics when setting empty or nil indexes + k.SetHardSupplyRewardIndexes(ctx, rewardPeriod.CollateralType, acc.Indexes) + } +} + +// getHardSupplyTotalSourceShares fetches the sum of all source shares for a supply reward. +// In the case of hard supply, this is the total supplied divided by the supply interest factor. +// This give the "pre interest" value of the total supplied. +func (k Keeper) getHardSupplyTotalSourceShares(ctx sdk.Context, denom string) sdk.Dec { + totalSuppliedCoins, found := k.hardKeeper.GetSuppliedCoins(ctx) if !found { - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + // assume no coins have been supplied + totalSuppliedCoins = sdk.NewCoins() + } + totalSupplied := totalSuppliedCoins.AmountOf(denom) + + interestFactor, found := k.hardKeeper.GetSupplyInterestFactor(ctx, denom) + if !found { + // assume nothing has been borrowed so the factor starts at it's default value + interestFactor = sdk.OneDec() } - newRewardIndexes := previousRewardIndexes - for _, rewardCoin := range rewardPeriod.RewardsPerSecond { - newRewards := rewardCoin.Amount.ToDec().Mul(timeElapsed.ToDec()) - previousRewardIndex, found := previousRewardIndexes.GetRewardIndex(rewardCoin.Denom) - if !found { - previousRewardIndex = types.NewRewardIndex(rewardCoin.Denom, sdk.ZeroDec()) - } - - // Calculate new reward factor and update reward index - rewardFactor := newRewards.Mul(hardFactor).Quo(totalSupplied) - newRewardFactorValue := previousRewardIndex.RewardFactor.Add(rewardFactor) - newRewardIndex := types.NewRewardIndex(rewardCoin.Denom, newRewardFactorValue) - i, found := newRewardIndexes.GetFactorIndex(rewardCoin.Denom) - if found { - newRewardIndexes[i] = newRewardIndex - } else { - newRewardIndexes = append(newRewardIndexes, newRewardIndex) - } - } - k.SetHardSupplyRewardIndexes(ctx, rewardPeriod.CollateralType, newRewardIndexes) - k.SetPreviousHardSupplyRewardAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + // return supplied/factor to get the "pre interest" value of the current total supplied + return totalSupplied.ToDec().Quo(interestFactor) } // InitializeHardSupplyReward initializes the supply-side of a hard liquidity provider claim @@ -188,13 +168,6 @@ func (k Keeper) SynchronizeHardLiquidityProviderClaim(ctx sdk.Context, owner sdk } } -// ZeroHardLiquidityProviderClaim zeroes out the claim object's rewards and returns the updated claim object -func (k Keeper) ZeroHardLiquidityProviderClaim(ctx sdk.Context, claim types.HardLiquidityProviderClaim) types.HardLiquidityProviderClaim { - claim.Reward = sdk.NewCoins() - k.SetHardLiquidityProviderClaim(ctx, claim) - return claim -} - // SimulateHardSynchronization calculates a user's outstanding hard rewards by simulating reward synchronization func (k Keeper) SimulateHardSynchronization(ctx sdk.Context, claim types.HardLiquidityProviderClaim) types.HardLiquidityProviderClaim { // 1. Simulate Hard supply-side rewards @@ -300,29 +273,6 @@ func (k Keeper) SimulateHardSynchronization(ctx sdk.Context, claim types.HardLiq return claim } -// CalculateTimeElapsed calculates the number of reward-eligible seconds that have passed since the previous -// time rewards were accrued, taking into account the end time of the reward period -func CalculateTimeElapsed(start, end, blockTime time.Time, previousAccrualTime time.Time) sdk.Int { - if (end.Before(blockTime) && - (end.Before(previousAccrualTime) || end.Equal(previousAccrualTime))) || - (start.After(blockTime)) || - (start.Equal(blockTime)) { - return sdk.ZeroInt() - } - if start.After(previousAccrualTime) && start.Before(blockTime) { - previousAccrualTime = start - } - - if end.Before(blockTime) { - return sdk.MaxInt(sdk.ZeroInt(), sdk.NewInt(int64(math.RoundToEven( - end.Sub(previousAccrualTime).Seconds(), - )))) - } - return sdk.MaxInt(sdk.ZeroInt(), sdk.NewInt(int64(math.RoundToEven( - blockTime.Sub(previousAccrualTime).Seconds(), - )))) -} - // Set setDifference: A - B func setDifference(a, b []string) (diff []string) { m := make(map[string]bool) diff --git a/x/incentive/keeper/rewards_supply_accum_test.go b/x/incentive/keeper/rewards_supply_accum_test.go new file mode 100644 index 00000000..559f7186 --- /dev/null +++ b/x/incentive/keeper/rewards_supply_accum_test.go @@ -0,0 +1,316 @@ +package keeper_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/kava-labs/kava/x/incentive/types" +) + +type AccumulateSupplyRewardsTests struct { + unitTester +} + +func (suite *AccumulateSupplyRewardsTests) storedTimeEquals(denom string, expected time.Time) { + storedTime, found := suite.keeper.GetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom) + suite.True(found) + suite.Equal(expected, storedTime) +} + +func (suite *AccumulateSupplyRewardsTests) storedIndexesEqual(denom string, expected types.RewardIndexes) { + storedIndexes, found := suite.keeper.GetHardSupplyRewardIndexes(suite.ctx, denom) + suite.Equal(found, expected != nil) + suite.Equal(expected, storedIndexes) +} + +func TestAccumulateSupplyRewards(t *testing.T) { + suite.Run(t, new(AccumulateSupplyRewardsTests)) +} + +func (suite *AccumulateSupplyRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreased() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalSupply(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + suite.storeGlobalSupplyIndexes(types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + }) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + newAccrualTime := previousAccrualTime.Add(1 * time.Hour) + suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, newAccrualTime) + suite.storedIndexesEqual(denom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("7.22"), + }, + { + CollateralType: "ukava", + RewardFactor: d("3.64"), + }, + }) +} + +func (suite *AccumulateSupplyRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalSupply(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalSupplyIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, previousAccrualTime) + expected, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expected) +} + +func (suite *AccumulateSupplyRewardsTests) TestNoAccumulationWhenSourceSharesAreZero() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper() // zero total supplys + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalSupplyIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(7 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(denom, firstAccrualTime) + expected, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expected) +} + +func (suite *AccumulateSupplyRewardsTests) TestStateAddedWhenStateDoesNotExist() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalSupply(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // After the first accumulation only the current block time should be stored. + // The indexes will be empty as no time has passed since the previous block because it didn't exist. + suite.storedTimeEquals(denom, firstAccrualTime) + suite.storedIndexesEqual(denom, nil) + + secondAccrualTime := firstAccrualTime.Add(10 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(secondAccrualTime) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // After the second accumulation both current block time and indexes should be stored. + suite.storedTimeEquals(denom, secondAccrualTime) + suite.storedIndexesEqual(denom, types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.01"), + }, + }) +} + +func (suite *AccumulateSupplyRewardsTests) TestNoPanicWhenStateDoesNotExist() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper() + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(), + ) + + accrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(accrualTime) + + // Accumulate with no source shares and no rewards per second will result in no increment to the indexes. + // No increment and no previous indexes stored, results in an updated of nil. Setting this in the state panics. + // Check there is no panic. + suite.NotPanics(func() { + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + }) + + suite.storedTimeEquals(denom, accrualTime) + suite.storedIndexesEqual(denom, nil) +} + +func (suite *AccumulateSupplyRewardsTests) TestNoAccumulationWhenBeforeStartTime() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalSupply(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: denom, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "hard", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalSupplyIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(10 * time.Second) + + period := types.NewMultiRewardPeriod( + true, + denom, + firstAccrualTime.Add(time.Nanosecond), // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + + // The accrual time should be updated, but the indexes unchanged + suite.storedTimeEquals(denom, firstAccrualTime) + expectedIndexes, f := previousIndexes.Get(denom) + suite.True(f) + suite.storedIndexesEqual(denom, expectedIndexes) +} + +func (suite *AccumulateSupplyRewardsTests) TestPanicWhenCurrentTimeLessThanPrevious() { + denom := "bnb" + + hardKeeper := newFakeHardKeeper().addTotalSupply(c(denom, 1e6), d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, hardKeeper, nil, nil, nil) + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousHardSupplyRewardAccrualTime(suite.ctx, denom, previousAccrualTime) + + firstAccrualTime := time.Time{} + + period := types.NewMultiRewardPeriod( + true, + denom, + time.Time{}, // start time after accrual time + distantFuture, + cs(c("hard", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.Panics(func() { + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, period) + }) +} diff --git a/x/incentive/keeper/rewards_supply_test.go b/x/incentive/keeper/rewards_supply_test.go index 266ba6dc..8fcc1ffe 100644 --- a/x/incentive/keeper/rewards_supply_test.go +++ b/x/incentive/keeper/rewards_supply_test.go @@ -186,8 +186,7 @@ func (suite *SupplyRewardsTestSuite) TestAccumulateHardSupplyRewards() { // Accumulate hard supply rewards for the deposit denom multiRewardPeriod, found := suite.keeper.GetHardSupplyRewardPeriods(runCtx, tc.args.deposit.Denom) suite.Require().True(found) - err = suite.keeper.AccumulateHardSupplyRewards(runCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardSupplyRewards(runCtx, multiRewardPeriod) // Check that each expected reward index matches the current stored reward index for the denom globalRewardIndexes, found := suite.keeper.GetHardSupplyRewardIndexes(runCtx, tc.args.deposit.Denom) @@ -548,8 +547,8 @@ func (suite *SupplyRewardsTestSuite) TestSynchronizeHardSupplyReward() { // Accumulate hard supply-side rewards multiRewardPeriod, found := suite.keeper.GetHardSupplyRewardPeriods(blockCtx, tc.args.deposit.Denom) if found { - err := suite.keeper.AccumulateHardSupplyRewards(blockCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardSupplyRewards(blockCtx, multiRewardPeriod) + } } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) @@ -658,15 +657,13 @@ func (suite *SupplyRewardsTestSuite) TestSynchronizeHardSupplyReward() { // But new deposit denoms don't have their PreviousHardSupplyRewardAccrualTime set yet, // so we need to call the accumulation method once to set the initial reward accrual time if tc.args.deposit.Denom != tc.args.incentiveSupplyRewardDenom { - err = suite.keeper.AccumulateHardSupplyRewards(suite.ctx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, multiRewardPeriod) } // Now we can jump forward in time and accumulate rewards updatedBlockTime = previousBlockTime.Add(time.Duration(int(time.Second) * tc.args.updatedTimeDuration)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) - err = suite.keeper.AccumulateHardSupplyRewards(suite.ctx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardSupplyRewards(suite.ctx, multiRewardPeriod) // After we've accumulated, run synchronize deposit, found = suite.hardKeeper.GetDeposit(suite.ctx, userAddr) @@ -919,8 +916,7 @@ func (suite *SupplyRewardsTestSuite) TestSimulateHardSupplyRewardSynchronization // Accumulate hard supply-side rewards multiRewardPeriod, found := suite.keeper.GetHardSupplyRewardPeriods(blockCtx, tc.args.deposit.Denom) suite.Require().True(found) - err := suite.keeper.AccumulateHardSupplyRewards(blockCtx, multiRewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateHardSupplyRewards(blockCtx, multiRewardPeriod) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) diff --git a/x/incentive/keeper/rewards_swap.go b/x/incentive/keeper/rewards_swap.go index bd68e020..9e25ac46 100644 --- a/x/incentive/keeper/rewards_swap.go +++ b/x/incentive/keeper/rewards_swap.go @@ -24,12 +24,9 @@ func (k Keeper) AccumulateSwapRewards(ctx sdk.Context, rewardPeriod types.MultiR acc := types.NewAccumulator(previousAccrualTime, indexes) - totalShares, found := k.swapKeeper.GetPoolShares(ctx, rewardPeriod.CollateralType) - if !found { - totalShares = sdk.ZeroInt() - } + totalSource := k.getSwapTotalSourceShares(ctx, rewardPeriod.CollateralType) - acc.Accumulate(rewardPeriod, totalShares.ToDec(), ctx.BlockTime()) + acc.Accumulate(rewardPeriod, totalSource, ctx.BlockTime()) k.SetSwapRewardAccrualTime(ctx, rewardPeriod.CollateralType, acc.PreviousAccumulationTime) if len(acc.Indexes) > 0 { @@ -38,6 +35,16 @@ func (k Keeper) AccumulateSwapRewards(ctx sdk.Context, rewardPeriod types.MultiR } } +// getSwapTotalSourceShares fetches the sum of all source shares for a swap reward. +// In the case of swap, these are the total (swap module) shares in a particular pool. +func (k Keeper) getSwapTotalSourceShares(ctx sdk.Context, poolID string) sdk.Dec { + totalShares, found := k.swapKeeper.GetPoolShares(ctx, poolID) + if !found { + totalShares = sdk.ZeroInt() + } + return totalShares.ToDec() +} + // InitializeSwapReward creates a new claim with zero rewards and indexes matching the global indexes. // If the claim already exists it just updates the indexes. func (k Keeper) InitializeSwapReward(ctx sdk.Context, poolID string, owner sdk.AccAddress) { @@ -107,7 +114,7 @@ func (k Keeper) GetSynchronizedSwapClaim(ctx sdk.Context, owner sdk.AccAddress) if !found { return types.SwapClaim{}, false } - for _, indexes := range claim.RewardIndexes { + for _, indexes := range claim.RewardIndexes { // TODO shouldn't this loop through global indexes, in case some have been recently added? poolID := indexes.CollateralType shares, found := k.swapKeeper.GetDepositorSharesAmount(ctx, owner, poolID) diff --git a/x/incentive/keeper/rewards_swap_test.go b/x/incentive/keeper/rewards_swap_accum_test.go similarity index 55% rename from x/incentive/keeper/rewards_swap_test.go rename to x/incentive/keeper/rewards_swap_accum_test.go index f368cab5..c921f859 100644 --- a/x/incentive/keeper/rewards_swap_test.go +++ b/x/incentive/keeper/rewards_swap_accum_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/suite" "github.com/kava-labs/kava/x/incentive/types" @@ -14,15 +13,15 @@ type AccumulateSwapRewardsTests struct { unitTester } -func (suite *AccumulateSwapRewardsTests) checkStoredTimeEquals(poolID string, expected time.Time) { +func (suite *AccumulateSwapRewardsTests) storedTimeEquals(poolID string, expected time.Time) { storedTime, found := suite.keeper.GetSwapRewardAccrualTime(suite.ctx, poolID) suite.True(found) suite.Equal(expected, storedTime) } -func (suite *AccumulateSwapRewardsTests) checkStoredIndexesEqual(poolID string, expected types.RewardIndexes) { +func (suite *AccumulateSwapRewardsTests) storedIndexesEqual(poolID string, expected types.RewardIndexes) { storedIndexes, found := suite.keeper.GetSwapRewardIndexes(suite.ctx, poolID) - suite.True(found) + suite.Equal(found, expected != nil) suite.Equal(expected, storedIndexes) } @@ -31,10 +30,11 @@ func TestAccumulateSwapRewards(t *testing.T) { } func (suite *AccumulateSwapRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreased() { - swapKeeper := &fakeSwapKeeper{i(1e6)} + pool := "btc:usdx" + + swapKeeper := newFakeSwapKeeper().addPool(pool, i(1e6)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) - pool := "btc/usdx" suite.storeGlobalSwapIndexes(types.MultiRewardIndexes{ { CollateralType: pool, @@ -68,9 +68,8 @@ func (suite *AccumulateSwapRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas // check time and factors - suite.checkStoredTimeEquals(pool, newAccrualTime) - - expectedIndexes := types.RewardIndexes{ + suite.storedTimeEquals(pool, newAccrualTime) + suite.storedIndexesEqual(pool, types.RewardIndexes{ { CollateralType: "swap", RewardFactor: d("7.22"), @@ -79,62 +78,16 @@ func (suite *AccumulateSwapRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas CollateralType: "ukava", RewardFactor: d("3.64"), }, - } - suite.checkStoredIndexesEqual(pool, expectedIndexes) -} - -func (suite *AccumulateSwapRewardsTests) TestLimitsOfAccumulationPrecision() { - swapKeeper := &fakeSwapKeeper{i(1e17)} // approximate shares in a $1B pool of 10^8 precision ~$1 asset - suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) - - pool := "btc/usdx" - suite.storeGlobalSwapIndexes(types.MultiRewardIndexes{ - { - CollateralType: pool, - RewardIndexes: types.RewardIndexes{ - { - CollateralType: "swap", - RewardFactor: d("0.0"), - }, - }, - }, }) - previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) - suite.keeper.SetSwapRewardAccrualTime(suite.ctx, pool, previousAccrualTime) - - newAccrualTime := previousAccrualTime.Add(1 * time.Second) // 1 second is the smallest increment accrual happens over - suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) - - period := types.NewMultiRewardPeriod( - true, - pool, - time.Unix(0, 0), - distantFuture, - cs(c("swap", 1)), // single unit of any denom is the smallest reward amount - ) - - suite.keeper.AccumulateSwapRewards(suite.ctx, period) - - // check time and factors - - suite.checkStoredTimeEquals(pool, newAccrualTime) - - expectedIndexes := types.RewardIndexes{ - { - CollateralType: "swap", - // smallest reward amount over smallest accumulation duration does not go past 10^-18 decimal precision - RewardFactor: d("0.000000000000000010"), - }, - } - suite.checkStoredIndexesEqual(pool, expectedIndexes) } func (suite *AccumulateSwapRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { - swapKeeper := &fakeSwapKeeper{i(1e6)} + pool := "btc:usdx" + + swapKeeper := newFakeSwapKeeper().addPool(pool, i(1e6)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) - pool := "btc/usdx" - suite.storeGlobalSwapIndexes(types.MultiRewardIndexes{ + previousIndexes := types.MultiRewardIndexes{ { CollateralType: pool, RewardIndexes: types.RewardIndexes{ @@ -148,7 +101,8 @@ func (suite *AccumulateSwapRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIn }, }, }, - }) + } + suite.storeGlobalSwapIndexes(previousIndexes) previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) suite.keeper.SetSwapRewardAccrualTime(suite.ctx, pool, previousAccrualTime) @@ -166,26 +120,63 @@ func (suite *AccumulateSwapRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIn // check time and factors - suite.checkStoredTimeEquals(pool, previousAccrualTime) + suite.storedTimeEquals(pool, previousAccrualTime) + expected, f := previousIndexes.Get(pool) + suite.True(f) + suite.storedIndexesEqual(pool, expected) +} - expectedIndexes := types.RewardIndexes{ +func (suite *AccumulateSwapRewardsTests) TestNoAccumulationWhenSourceSharesAreZero() { + pool := "btc:usdx" + + swapKeeper := newFakeSwapKeeper() // no pools, so no source shares + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) + + previousIndexes := types.MultiRewardIndexes{ { - CollateralType: "swap", - RewardFactor: d("0.02"), - }, - { - CollateralType: "ukava", - RewardFactor: d("0.04"), + CollateralType: pool, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "swap", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, }, } - suite.checkStoredIndexesEqual(pool, expectedIndexes) + suite.storeGlobalSwapIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetSwapRewardAccrualTime(suite.ctx, pool, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(7 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + period := types.NewMultiRewardPeriod( + true, + pool, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(c("swap", 2000), c("ukava", 1000)), // same denoms as in global indexes + ) + + suite.keeper.AccumulateSwapRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(pool, firstAccrualTime) + expected, f := previousIndexes.Get(pool) + suite.True(f) + suite.storedIndexesEqual(pool, expected) } func (suite *AccumulateSwapRewardsTests) TestStateAddedWhenStateDoesNotExist() { - swapKeeper := &fakeSwapKeeper{i(1e6)} - suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) + pool := "btc:usdx" - pool := "btc/usdx" + swapKeeper := newFakeSwapKeeper().addPool(pool, i(1e6)) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) period := types.NewMultiRewardPeriod( true, @@ -201,8 +192,9 @@ func (suite *AccumulateSwapRewardsTests) TestStateAddedWhenStateDoesNotExist() { suite.keeper.AccumulateSwapRewards(suite.ctx, period) // After the first accumulation only the current block time should be stored. - // This indexes will be zero as no time has passed since the previous block because it didn't exist. - suite.checkStoredTimeEquals(pool, firstAccrualTime) + // The indexes will be empty as no time has passed since the previous block because it didn't exist. + suite.storedTimeEquals(pool, firstAccrualTime) + suite.storedIndexesEqual(pool, nil) secondAccrualTime := firstAccrualTime.Add(10 * time.Second) suite.ctx = suite.ctx.WithBlockTime(secondAccrualTime) @@ -210,9 +202,8 @@ func (suite *AccumulateSwapRewardsTests) TestStateAddedWhenStateDoesNotExist() { suite.keeper.AccumulateSwapRewards(suite.ctx, period) // After the second accumulation both current block time and indexes should be stored. - suite.checkStoredTimeEquals(pool, secondAccrualTime) - - expectedIndexes := types.RewardIndexes{ + suite.storedTimeEquals(pool, secondAccrualTime) + suite.storedIndexesEqual(pool, types.RewardIndexes{ { CollateralType: "swap", RewardFactor: d("0.02"), @@ -221,14 +212,14 @@ func (suite *AccumulateSwapRewardsTests) TestStateAddedWhenStateDoesNotExist() { CollateralType: "ukava", RewardFactor: d("0.01"), }, - } - suite.checkStoredIndexesEqual(pool, expectedIndexes) + }) } -func (suite *AccumulateSwapRewardsTests) TestNoPanicWhenStateDoesNotExist() { - swapKeeper := &fakeSwapKeeper{i(0)} - suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) - pool := "btc/usdx" +func (suite *AccumulateSwapRewardsTests) TestNoPanicWhenStateDoesNotExist() { + pool := "btc:usdx" + + swapKeeper := newFakeSwapKeeper() + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) period := types.NewMultiRewardPeriod( true, @@ -248,20 +239,78 @@ func (suite *AccumulateSwapRewardsTests) TestNoPanicWhenStateDoesNotExist() { suite.keeper.AccumulateSwapRewards(suite.ctx, period) }) - suite.checkStoredTimeEquals(pool, accrualTime) + suite.storedTimeEquals(pool, accrualTime) + suite.storedIndexesEqual(pool, nil) } -type fakeSwapKeeper struct { - poolShares sdk.Int +func (suite *AccumulateSwapRewardsTests) TestNoAccumulationWhenBeforeStartTime() { + pool := "btc:usdx" + + swapKeeper := newFakeSwapKeeper().addPool(pool, i(1e6)) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) + + previousIndexes := types.MultiRewardIndexes{ + { + CollateralType: pool, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "swap", + RewardFactor: d("0.02"), + }, + { + CollateralType: "ukava", + RewardFactor: d("0.04"), + }, + }, + }, + } + suite.storeGlobalSwapIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetSwapRewardAccrualTime(suite.ctx, pool, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(10 * time.Second) + + period := types.NewMultiRewardPeriod( + true, + pool, + firstAccrualTime.Add(time.Nanosecond), // start time after accrual time + distantFuture, + cs(c("swap", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateSwapRewards(suite.ctx, period) + + // The accrual time should be updated, but the indexes unchanged + suite.storedTimeEquals(pool, firstAccrualTime) + expectedIndexes, f := previousIndexes.Get(pool) + suite.True(f) + suite.storedIndexesEqual(pool, expectedIndexes) } -func (k fakeSwapKeeper) GetPoolShares(ctx sdk.Context, poolID string) (sdk.Int, bool) { - return k.poolShares, true -} -func (k fakeSwapKeeper) GetDepositorSharesAmount(ctx sdk.Context, depositor sdk.AccAddress, poolID string) (sdk.Int, bool) { - // This is just to implement the swap keeper interface. - return sdk.Int{}, false -} +func (suite *AccumulateSwapRewardsTests) TestPanicWhenCurrentTimeLessThanPrevious() { + pool := "btc:usdx" -// note: amino panics when encoding times ≥ the start of year 10000. -var distantFuture = time.Date(9000, 1, 1, 0, 0, 0, 0, time.UTC) + swapKeeper := newFakeSwapKeeper().addPool(pool, i(1e6)) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper) + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetSwapRewardAccrualTime(suite.ctx, pool, previousAccrualTime) + + firstAccrualTime := time.Time{} + + period := types.NewMultiRewardPeriod( + true, + pool, + time.Time{}, // start time after accrual time + distantFuture, + cs(c("swap", 2000), c("ukava", 1000)), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.Panics(func() { + suite.keeper.AccumulateSwapRewards(suite.ctx, period) + }) +} diff --git a/x/incentive/keeper/rewards_swap_init_test.go b/x/incentive/keeper/rewards_swap_init_test.go index e2815195..37619e38 100644 --- a/x/incentive/keeper/rewards_swap_init_test.go +++ b/x/incentive/keeper/rewards_swap_init_test.go @@ -29,7 +29,7 @@ func (suite *InitializeSwapRewardTests) TestClaimAddedWhenClaimDoesNotExistAndNo // When a claim doesn't exist, and a user deposits to a non-rewarded pool; // then a claim is added with no rewards and no indexes - poolID := "base/quote" + poolID := "base:quote" // no global indexes stored as this pool is not rewarded @@ -53,7 +53,7 @@ func (suite *InitializeSwapRewardTests) TestClaimAddedWhenClaimDoesNotExistAndRe // When a claim doesn't exist, and a user deposits to a rewarded pool; // then a claim is added with no rewards and indexes matching the global indexes - poolID := "base/quote" + poolID := "base:quote" globalIndexes := types.MultiRewardIndexes{ { @@ -92,7 +92,7 @@ func (suite *InitializeSwapRewardTests) TestClaimUpdatedWhenClaimExistsAndNoRewa }, } - newPoolID := "btcb/usdx" + newPoolID := "btcb:usdx" claim := types.SwapClaim{ BaseMultiClaim: types.BaseMultiClaim{ @@ -141,7 +141,7 @@ func (suite *InitializeSwapRewardTests) TestClaimUpdatedWhenClaimExistsAndReward }, } - newPoolID := "btcb/usdx" + newPoolID := "btcb:usdx" newIndexes := types.RewardIndexes{ { CollateralType: "otherrewarddenom", diff --git a/x/incentive/keeper/rewards_swap_sync_test.go b/x/incentive/keeper/rewards_swap_sync_test.go index 74b62afa..02a97416 100644 --- a/x/incentive/keeper/rewards_swap_sync_test.go +++ b/x/incentive/keeper/rewards_swap_sync_test.go @@ -32,7 +32,7 @@ func (suite *SynchronizeSwapRewardTests) TestClaimUpdatedWhenGlobalIndexesHaveIn // The user earns rewards for the time passed, and the claim indexes are updated originalReward := arbitraryCoins() - poolID := "base/quote" + poolID := "base:quote" claim := types.SwapClaim{ BaseMultiClaim: types.BaseMultiClaim{ @@ -83,7 +83,7 @@ func (suite *SynchronizeSwapRewardTests) TestClaimUpdatedWhenGlobalIndexesHaveIn func (suite *SynchronizeSwapRewardTests) TestClaimUnchangedWhenGlobalIndexesUnchanged() { // It should be safe to call SynchronizeSwapReward multiple times - poolID := "base/quote" + poolID := "base:quote" unchangingIndexes := types.MultiRewardIndexes{ { CollateralType: poolID, @@ -211,7 +211,7 @@ func (suite *SynchronizeSwapRewardTests) TestClaimUpdatedWhenNewRewardDenomAdded // Then the user earns rewards for the time since the reward was added, and the new indexes are added. originalReward := arbitraryCoins() - poolID := "base/quote" + poolID := "base:quote" claim := types.SwapClaim{ BaseMultiClaim: types.BaseMultiClaim{ @@ -271,7 +271,7 @@ func (suite *SynchronizeSwapRewardTests) TestClaimUpdatedWhenGlobalIndexesIncrea // When the claim is synced, but the user has no shares // The user earns no rewards for the time passed, but the claim indexes are updated - poolID := "base/quote" + poolID := "base:quote" claim := types.SwapClaim{ BaseMultiClaim: types.BaseMultiClaim{ diff --git a/x/incentive/keeper/rewards_usdx.go b/x/incentive/keeper/rewards_usdx.go index 088017c7..4c45f97c 100644 --- a/x/incentive/keeper/rewards_usdx.go +++ b/x/incentive/keeper/rewards_usdx.go @@ -9,42 +9,49 @@ import ( "github.com/kava-labs/kava/x/incentive/types" ) -// AccumulateUSDXMintingRewards updates the rewards accumulated for the input reward period -func (k Keeper) AccumulateUSDXMintingRewards(ctx sdk.Context, rewardPeriod types.RewardPeriod) error { +// AccumulateUSDXMintingRewards calculates new rewards to distribute this block and updates the global indexes to reflect this. +// The provided rewardPeriod must be valid to avoid panics in calculating time durations. +func (k Keeper) AccumulateUSDXMintingRewards(ctx sdk.Context, rewardPeriod types.RewardPeriod) { previousAccrualTime, found := k.GetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType) if !found { - k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + previousAccrualTime = ctx.BlockTime() } - timeElapsed := CalculateTimeElapsed(rewardPeriod.Start, rewardPeriod.End, ctx.BlockTime(), previousAccrualTime) - if timeElapsed.IsZero() { - return nil - } - if rewardPeriod.RewardsPerSecond.Amount.IsZero() { - k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - totalPrincipal := k.cdpKeeper.GetTotalPrincipal(ctx, rewardPeriod.CollateralType, types.PrincipalDenom).ToDec() - if totalPrincipal.IsZero() { - k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - newRewards := timeElapsed.Mul(rewardPeriod.RewardsPerSecond.Amount) - cdpFactor, found := k.cdpKeeper.GetInterestFactor(ctx, rewardPeriod.CollateralType) - if !found { - k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil - } - rewardFactor := newRewards.ToDec().Mul(cdpFactor).Quo(totalPrincipal) - previousRewardFactor, found := k.GetUSDXMintingRewardFactor(ctx, rewardPeriod.CollateralType) + factor, found := k.GetUSDXMintingRewardFactor(ctx, rewardPeriod.CollateralType) if !found { - previousRewardFactor = sdk.ZeroDec() + factor = sdk.ZeroDec() } - newRewardFactor := previousRewardFactor.Add(rewardFactor) - k.SetUSDXMintingRewardFactor(ctx, rewardPeriod.CollateralType, newRewardFactor) - k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) - return nil + // wrap in RewardIndexes for compatibility with Accumulator + indexes := types.RewardIndexes{}.With(types.USDXMintingRewardDenom, factor) + + acc := types.NewAccumulator(previousAccrualTime, indexes) + + totalSource := k.getUSDXTotalSourceShares(ctx, rewardPeriod.CollateralType) + + acc.Accumulate(types.NewMultiRewardPeriodFromRewardPeriod(rewardPeriod), totalSource, ctx.BlockTime()) + + k.SetPreviousUSDXMintingAccrualTime(ctx, rewardPeriod.CollateralType, acc.PreviousAccumulationTime) + + factor, found = acc.Indexes.Get(types.USDXMintingRewardDenom) + if !found { + panic("could not find factor that should never be missing when accumulating usdx rewards") + } + k.SetUSDXMintingRewardFactor(ctx, rewardPeriod.CollateralType, factor) +} + +// getUSDXTotalSourceShares fetches the sum of all source shares for a usdx minting reward. +// In the case of usdx minting, this is the total debt from all cdps of a particular type, divided by the cdp interest factor. +// This give the "pre interest" value of the total debt. +func (k Keeper) getUSDXTotalSourceShares(ctx sdk.Context, collateralType string) sdk.Dec { + totalPrincipal := k.cdpKeeper.GetTotalPrincipal(ctx, collateralType, cdptypes.DefaultStableDenom) + + cdpFactor, found := k.cdpKeeper.GetInterestFactor(ctx, collateralType) + if !found { + // assume nothing has been borrowed so the factor starts at it's default value + cdpFactor = sdk.OneDec() + } + // return debt/factor to get the "pre interest" value of the current total debt + return totalPrincipal.ToDec().Quo(cdpFactor) } // InitializeUSDXMintingClaim creates or updates a claim such that no new rewards are accrued, but any existing rewards are not lost. diff --git a/x/incentive/keeper/rewards_usdx_accum_test.go b/x/incentive/keeper/rewards_usdx_accum_test.go new file mode 100644 index 00000000..ad166e2c --- /dev/null +++ b/x/incentive/keeper/rewards_usdx_accum_test.go @@ -0,0 +1,234 @@ +package keeper_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" + + "github.com/kava-labs/kava/x/incentive/types" +) + +type AccumulateUSDXRewardsTests struct { + usdxRewardsUnitTester +} + +func (suite *AccumulateUSDXRewardsTests) storedTimeEquals(cType string, expected time.Time) { + storedTime, found := suite.keeper.GetPreviousUSDXMintingAccrualTime(suite.ctx, cType) + suite.True(found) + suite.Equal(expected, storedTime) +} + +func (suite *AccumulateUSDXRewardsTests) storedIndexesEqual(cType string, expected sdk.Dec) { + storedIndexes, found := suite.keeper.GetUSDXMintingRewardFactor(suite.ctx, cType) + suite.True(found) + suite.Equal(expected, storedIndexes) +} + +func TestAccumulateUSDXRewards(t *testing.T) { + suite.Run(t, new(AccumulateUSDXRewardsTests)) +} + +func (suite *AccumulateUSDXRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreased() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper().addTotalPrincipal(i(1e6)).addInterestFactor(d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + suite.storeGlobalUSDXIndexes(types.RewardIndexes{ + { + CollateralType: cType, + RewardFactor: d("0.04"), + }, + }) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousUSDXMintingAccrualTime(suite.ctx, cType, previousAccrualTime) + + newAccrualTime := previousAccrualTime.Add(1 * time.Hour) + suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) + + period := types.NewRewardPeriod( + true, + cType, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + c("ukava", 1000), + ) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(cType, newAccrualTime) + suite.storedIndexesEqual(cType, d("3.64")) +} + +func (suite *AccumulateUSDXRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper().addTotalPrincipal(i(1e6)).addInterestFactor(d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + previousIndexes := types.RewardIndexes{ + { + CollateralType: cType, + RewardFactor: d("0.04"), + }, + } + suite.storeGlobalUSDXIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousUSDXMintingAccrualTime(suite.ctx, cType, previousAccrualTime) + + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + + period := types.NewRewardPeriod( + true, + cType, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + c("ukava", 2000), + ) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(cType, previousAccrualTime) + expected, f := previousIndexes.Get(cType) + suite.True(f) + suite.storedIndexesEqual(cType, expected) +} + +func (suite *AccumulateUSDXRewardsTests) TestNoAccumulationWhenSourceSharesAreZero() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper() // zero total borrows + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + previousIndexes := types.RewardIndexes{ + { + CollateralType: cType, + RewardFactor: d("0.04"), + }, + } + suite.storeGlobalUSDXIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousUSDXMintingAccrualTime(suite.ctx, cType, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(7 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + period := types.NewRewardPeriod( + true, + cType, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + c("ukava", 1000), + ) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // check time and factors + + suite.storedTimeEquals(cType, firstAccrualTime) + expected, f := previousIndexes.Get(cType) + suite.True(f) + suite.storedIndexesEqual(cType, expected) +} + +func (suite *AccumulateUSDXRewardsTests) TestStateAddedWhenStateDoesNotExist() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper().addTotalPrincipal(i(1e6)).addInterestFactor(d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + period := types.NewRewardPeriod( + true, + cType, + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + c("ukava", 1000), + ) + + firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // After the first accumulation the current block time should be stored and the factor will be zero. + suite.storedTimeEquals(cType, firstAccrualTime) + suite.storedIndexesEqual(cType, sdk.ZeroDec()) + + secondAccrualTime := firstAccrualTime.Add(10 * time.Second) + suite.ctx = suite.ctx.WithBlockTime(secondAccrualTime) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // After the second accumulation both current block time and indexes should be stored. + suite.storedTimeEquals(cType, secondAccrualTime) + suite.storedIndexesEqual(cType, d("0.01")) +} + +func (suite *AccumulateUSDXRewardsTests) TestNoAccumulationWhenBeforeStartTime() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper().addTotalPrincipal(i(1e6)).addInterestFactor(d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + previousIndexes := types.RewardIndexes{ + { + CollateralType: cType, + RewardFactor: d("0.04"), + }, + } + suite.storeGlobalUSDXIndexes(previousIndexes) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousUSDXMintingAccrualTime(suite.ctx, cType, previousAccrualTime) + + firstAccrualTime := previousAccrualTime.Add(10 * time.Second) + + period := types.NewRewardPeriod( + true, + cType, + firstAccrualTime.Add(time.Nanosecond), // start time after accrual time + distantFuture, + c("ukava", 1000), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + + // The accrual time should be updated, but the indexes unchanged + suite.storedTimeEquals(cType, firstAccrualTime) + expected, f := previousIndexes.Get(cType) + suite.True(f) + suite.storedIndexesEqual(cType, expected) +} + +func (suite *AccumulateUSDXRewardsTests) TestPanicWhenCurrentTimeLessThanPrevious() { + cType := "bnb-a" + + cdpKeeper := newFakeCDPKeeper().addTotalPrincipal(i(1e6)).addInterestFactor(d("1")) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, cdpKeeper, nil, nil, nil, nil) + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetPreviousUSDXMintingAccrualTime(suite.ctx, cType, previousAccrualTime) + + firstAccrualTime := time.Time{} + + period := types.NewRewardPeriod( + true, + cType, + time.Time{}, // start time after accrual time + distantFuture, + c("ukava", 1000), + ) + + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + + suite.Panics(func() { + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, period) + }) +} diff --git a/x/incentive/keeper/rewards_usdx_test.go b/x/incentive/keeper/rewards_usdx_test.go index 73fdd230..7f656db0 100644 --- a/x/incentive/keeper/rewards_usdx_test.go +++ b/x/incentive/keeper/rewards_usdx_test.go @@ -117,8 +117,7 @@ func (suite *USDXRewardsTestSuite) TestAccumulateUSDXMintingRewards() { suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) rewardPeriod, found := suite.keeper.GetUSDXMintingRewardPeriod(suite.ctx, tc.args.ctype) suite.Require().True(found) - err := suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, rewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateUSDXMintingRewards(suite.ctx, rewardPeriod) rewardFactor, _ := suite.keeper.GetUSDXMintingRewardFactor(suite.ctx, tc.args.ctype) suite.Require().Equal(tc.args.expectedRewardFactor, rewardFactor) @@ -191,8 +190,7 @@ func (suite *USDXRewardsTestSuite) TestSynchronizeUSDXMintingReward() { blockCtx := suite.ctx.WithBlockTime(updatedBlockTime) rewardPeriod, found := suite.keeper.GetUSDXMintingRewardPeriod(blockCtx, tc.args.ctype) suite.Require().True(found) - err := suite.keeper.AccumulateUSDXMintingRewards(blockCtx, rewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateUSDXMintingRewards(blockCtx, rewardPeriod) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) @@ -278,8 +276,7 @@ func (suite *USDXRewardsTestSuite) TestSimulateUSDXMintingRewardSynchronization( blockCtx := suite.ctx.WithBlockTime(updatedBlockTime) rewardPeriod, found := suite.keeper.GetUSDXMintingRewardPeriod(blockCtx, tc.args.ctype) suite.Require().True(found) - err := suite.keeper.AccumulateUSDXMintingRewards(blockCtx, rewardPeriod) - suite.Require().NoError(err) + suite.keeper.AccumulateUSDXMintingRewards(blockCtx, rewardPeriod) } updatedBlockTime := suite.ctx.BlockTime().Add(time.Duration(int(time.Second) * timeElapsed)) suite.ctx = suite.ctx.WithBlockTime(updatedBlockTime) diff --git a/x/incentive/keeper/unit_test.go b/x/incentive/keeper/unit_test.go index 41c29269..32d707d4 100644 --- a/x/incentive/keeper/unit_test.go +++ b/x/incentive/keeper/unit_test.go @@ -2,17 +2,21 @@ package keeper_test import ( "fmt" + "time" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/store" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/params" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/stretchr/testify/suite" abci "github.com/tendermint/tendermint/abci/types" "github.com/tendermint/tendermint/libs/log" db "github.com/tendermint/tm-db" "github.com/kava-labs/kava/app" + cdptypes "github.com/kava-labs/kava/x/cdp/types" + hardtypes "github.com/kava-labs/kava/x/hard/types" "github.com/kava-labs/kava/x/incentive/keeper" "github.com/kava-labs/kava/x/incentive/types" ) @@ -94,6 +98,7 @@ func (suite *unitTester) storeSwapClaim(claim types.SwapClaim) { suite.keeper.SetSwapClaim(suite.ctx, claim) } +// fakeParamSubspace is a stub paramSpace to simplify keeper unit test setup. type fakeParamSubspace struct { params types.Params } @@ -113,6 +118,197 @@ func (subspace *fakeParamSubspace) WithKeyTable(params.KeyTable) params.Subspace return params.Subspace{} } +// fakeSwapKeeper is a stub swap keeper. +// It can be used to return values to the incentive keeper without having to initialize a full swap keeper. +type fakeSwapKeeper struct { + poolShares map[string]sdk.Int +} + +var _ types.SwapKeeper = newFakeSwapKeeper() + +func newFakeSwapKeeper() *fakeSwapKeeper { + return &fakeSwapKeeper{ + poolShares: map[string]sdk.Int{}, + } +} +func (k *fakeSwapKeeper) addPool(id string, shares sdk.Int) *fakeSwapKeeper { + k.poolShares[id] = shares + return k +} +func (k *fakeSwapKeeper) GetPoolShares(_ sdk.Context, poolID string) (sdk.Int, bool) { + shares, ok := k.poolShares[poolID] + return shares, ok +} +func (k *fakeSwapKeeper) GetDepositorSharesAmount(_ sdk.Context, depositor sdk.AccAddress, poolID string) (sdk.Int, bool) { + // This is just to implement the swap keeper interface. + return sdk.Int{}, false +} + +// fakeHardKeeper is a stub hard keeper. +// It can be used to return values to the incentive keeper without having to initialize a full hard keeper. +type fakeHardKeeper struct { + borrows fakeHardState + deposits fakeHardState +} + +type fakeHardState struct { + total sdk.Coins + interestFactors map[string]sdk.Dec +} + +func newFakeHardState() fakeHardState { + return fakeHardState{ + total: nil, + interestFactors: map[string]sdk.Dec{}, // initialize map to avoid panics on read + } +} + +var _ types.HardKeeper = newFakeHardKeeper() + +func newFakeHardKeeper() *fakeHardKeeper { + return &fakeHardKeeper{ + borrows: newFakeHardState(), + deposits: newFakeHardState(), + } +} + +func (k *fakeHardKeeper) addTotalBorrow(coin sdk.Coin, factor sdk.Dec) *fakeHardKeeper { + k.borrows.total = k.borrows.total.Add(coin) + k.borrows.interestFactors[coin.Denom] = factor + return k +} +func (k *fakeHardKeeper) addTotalSupply(coin sdk.Coin, factor sdk.Dec) *fakeHardKeeper { + k.deposits.total = k.deposits.total.Add(coin) + k.deposits.interestFactors[coin.Denom] = factor + return k +} + +func (k *fakeHardKeeper) GetBorrowedCoins(_ sdk.Context) (sdk.Coins, bool) { + if k.borrows.total == nil { + return nil, false + } + return k.borrows.total, true +} +func (k *fakeHardKeeper) GetSuppliedCoins(_ sdk.Context) (sdk.Coins, bool) { + if k.deposits.total == nil { + return nil, false + } + return k.deposits.total, true +} +func (k *fakeHardKeeper) GetBorrowInterestFactor(_ sdk.Context, denom string) (sdk.Dec, bool) { + f, ok := k.borrows.interestFactors[denom] + return f, ok +} +func (k *fakeHardKeeper) GetSupplyInterestFactor(_ sdk.Context, denom string) (sdk.Dec, bool) { + f, ok := k.deposits.interestFactors[denom] + return f, ok +} +func (k *fakeHardKeeper) GetBorrow(_ sdk.Context, _ sdk.AccAddress) (hardtypes.Borrow, bool) { + panic("unimplemented") +} +func (k *fakeHardKeeper) GetDeposit(_ sdk.Context, _ sdk.AccAddress) (hardtypes.Deposit, bool) { + panic("unimplemented") +} + +// fakeStakingKeeper is a stub staking keeper. +// It can be used to return values to the incentive keeper without having to initialize a full staking keeper. +type fakeStakingKeeper struct { + delegations stakingtypes.Delegations + validators stakingtypes.Validators +} + +var _ types.StakingKeeper = newFakeStakingKeeper() + +func newFakeStakingKeeper() *fakeStakingKeeper { return &fakeStakingKeeper{} } + +func (k *fakeStakingKeeper) addBondedTokens(amount int64) *fakeStakingKeeper { + if len(k.validators) != 0 { + panic("cannot set total bonded if keeper already has validators set") + } + // add a validator with all the tokens + k.validators = append(k.validators, stakingtypes.Validator{ + Status: sdk.Bonded, + Tokens: sdk.NewInt(amount), + }) + return k +} + +func (k *fakeStakingKeeper) TotalBondedTokens(_ sdk.Context) sdk.Int { + total := sdk.ZeroInt() + for _, val := range k.validators { + if val.GetStatus() == sdk.Bonded { + total = total.Add(val.GetBondedTokens()) + } + } + return total +} +func (k *fakeStakingKeeper) GetDelegatorDelegations(_ sdk.Context, delegator sdk.AccAddress, maxRetrieve uint16) []stakingtypes.Delegation { + return k.delegations +} +func (k *fakeStakingKeeper) GetValidator(_ sdk.Context, addr sdk.ValAddress) (stakingtypes.Validator, bool) { + for _, val := range k.validators { + if val.GetOperator().Equals(addr) { + return val, true + } + } + return stakingtypes.Validator{}, false +} +func (k *fakeStakingKeeper) GetValidatorDelegations(_ sdk.Context, valAddr sdk.ValAddress) []stakingtypes.Delegation { + var delegations stakingtypes.Delegations + for _, d := range k.delegations { + if d.ValidatorAddress.Equals(valAddr) { + delegations = append(delegations, d) + } + } + return delegations +} + +// fakeCDPKeeper is a stub cdp keeper. +// It can be used to return values to the incentive keeper without having to initialize a full cdp keeper. +type fakeCDPKeeper struct { + interestFactor *sdk.Dec + totalPrincipal sdk.Int +} + +var _ types.CdpKeeper = newFakeCDPKeeper() + +func newFakeCDPKeeper() *fakeCDPKeeper { + return &fakeCDPKeeper{ + interestFactor: nil, + totalPrincipal: sdk.ZeroInt(), + } +} + +func (k *fakeCDPKeeper) addInterestFactor(f sdk.Dec) *fakeCDPKeeper { + k.interestFactor = &f + return k +} +func (k *fakeCDPKeeper) addTotalPrincipal(p sdk.Int) *fakeCDPKeeper { + k.totalPrincipal = p + return k +} + +func (k *fakeCDPKeeper) GetInterestFactor(_ sdk.Context, collateralType string) (sdk.Dec, bool) { + if k.interestFactor != nil { + return *k.interestFactor, true + } + return sdk.Dec{}, false +} +func (k *fakeCDPKeeper) GetTotalPrincipal(_ sdk.Context, collateralType string, principalDenom string) sdk.Int { + return k.totalPrincipal +} +func (k *fakeCDPKeeper) GetCdpByOwnerAndCollateralType(_ sdk.Context, owner sdk.AccAddress, collateralType string) (cdptypes.CDP, bool) { + return cdptypes.CDP{}, false +} +func (k *fakeCDPKeeper) GetCollateral(_ sdk.Context, collateralType string) (cdptypes.CollateralParam, bool) { + return cdptypes.CollateralParam{}, false +} + +// Assorted Testing Data + +// note: amino panics when encoding times ≥ the start of year 10000. +var distantFuture = time.Date(9000, 1, 1, 0, 0, 0, 0, time.UTC) + func arbitraryCoin() sdk.Coin { return c("hard", 1e9) } diff --git a/x/incentive/spec/01_concepts.md b/x/incentive/spec/01_concepts.md index e727630c..d8bb5ad7 100644 --- a/x/incentive/spec/01_concepts.md +++ b/x/incentive/spec/01_concepts.md @@ -12,15 +12,15 @@ Rewards target various user activity. For example, usdx borrowed from bnb CDPs, Each second, the rewards accumulate at a rate set in the params, eg 100 ukava per second. These are then distributed to all users ratably based on their percentage involvement in the rewarded activity. For example if a user holds 1% of all funds deposited to the kava/usdx swap pool. They will receive 1% of the total rewards each second. -The number tracking a user's involvement is referred to as "reward source" in the code. And the total across all users the "reward source total". +The quantity tracking a user's involvement is referred to as "source shares" in the code. And the total across all users the "total source shares". The quotient then gives their percentage involvement, eg if a user borrowed 10,000 usdx, and there is 100,000 usdx borrowed by all users, then they will get 10% of rewards. ## Efficiency Paying out rewards to every user every block would be slow and lead to long block times. Instead rewards are calculated much less frequently. -Every block a global tracker adds up total rewards paid out per unit of user involvement. For example, per unit of xrpb supplied to hard, or per share in a kava/usdx swap pool. A user's specific reward can then be calculated as needed based on their current deposit/shares/borrow. +Every block a global tracker adds up total rewards paid out per unit of user involvement. For example, per unit of xrpb supplied to hard, or per share in a kava/usdx swap pool. A user's specific reward can then be calculated as needed based on their current source shares. -User's rewards must be updated whenever their reward source changes. This happens through hooks into other modules that run before deposits/borrows/supplies etc. +Users' rewards must be updated whenever their source shares change. This happens through hooks into other modules that run before deposits/borrows/supplies etc. ## HARD Token distribution @@ -39,3 +39,7 @@ The exact multipliers will be voted by governance and can be changed via a gover ## USDX Minting Rewards The incentive module is responsible for distribution of KAVA tokens to users who mint USDX. When governance adds a collateral type to be eligible for rewards, they set the rate (coins/second) at which rewards are given to users, the length of each reward period, the length of each claim period, and the amount of time reward coins must vest before users who claim them can transfer them. For the duration of a reward period, any user that has minted USDX using an eligible collateral type will ratably accumulate rewards in a `USDXMintingClaim` object. For example, if a user has minted 10% of all USDX for the duration of the reward period, they will earn 10% of all rewards for that period. When the reward period ends, the claim period begins immediately, at which point users can submit a message to claim their rewards. Rewards are time-locked, meaning that when a user claims rewards they will receive them as a vesting balance on their account. Vesting balances can be used to stake coins, but cannot be transferred until the vesting period ends. In addition to vesting, rewards can have multipliers that vary the number of tokens received. For example, a reward with a vesting period of 1 month may have a multiplier of 0.25, meaning that the user will receive 25% of the reward balance if they choose that vesting schedule. + +## SWP Token Distribution + +TODO \ No newline at end of file diff --git a/x/incentive/spec/README.md b/x/incentive/spec/README.md index d8f2eeba..0c1a5bb2 100644 --- a/x/incentive/spec/README.md +++ b/x/incentive/spec/README.md @@ -22,4 +22,4 @@ parent: ### Dependencies -This module uses hooks to update user rewards. Currently, `incentive` implements hooks from the `cdp`, `hard`, and `staking` (comsos-sdk) modules. All rewards are paid out from the `kavadist` module account. +This module uses hooks to update user rewards. Currently, `incentive` implements hooks from the `cdp`, `hard`, `swap`, and `staking` (comsos-sdk) modules. All rewards are paid out from the `kavadist` module account. diff --git a/x/incentive/testutil/builder.go b/x/incentive/testutil/builder.go index 07fc5f0f..a77e7b48 100644 --- a/x/incentive/testutil/builder.go +++ b/x/incentive/testutil/builder.go @@ -46,6 +46,8 @@ func (builder IncentiveGenesisBuilder) WithGenesisTime(time time.Time) Incentive return builder } +// WithInitializedBorrowRewardPeriod sets the genesis time as the previous accumulation time for the specified period. +// This can be helpful in tests. With no prev time set, the first block accrues no rewards as it just sets the prev time to the current. func (builder IncentiveGenesisBuilder) WithInitializedBorrowRewardPeriod(period types.MultiRewardPeriod) IncentiveGenesisBuilder { builder.Params.HardBorrowRewardPeriods = append(builder.Params.HardBorrowRewardPeriods, period) @@ -55,6 +57,7 @@ func (builder IncentiveGenesisBuilder) WithInitializedBorrowRewardPeriod(period accumulationTimeForPeriod, ) + // TODO remove to better reflect real states builder.HardBorrowRewardState.MultiRewardIndexes = builder.HardBorrowRewardState.MultiRewardIndexes.With( period.CollateralType, newZeroRewardIndexesFromCoins(period.RewardsPerSecond...), @@ -67,6 +70,8 @@ func (builder IncentiveGenesisBuilder) WithSimpleBorrowRewardPeriod(ctype string return builder.WithInitializedBorrowRewardPeriod(builder.simpleRewardPeriod(ctype, rewardsPerSecond)) } +// WithInitializedSupplyRewardPeriod sets the genesis time as the previous accumulation time for the specified period. +// This can be helpful in tests. With no prev time set, the first block accrues no rewards as it just sets the prev time to the current. func (builder IncentiveGenesisBuilder) WithInitializedSupplyRewardPeriod(period types.MultiRewardPeriod) IncentiveGenesisBuilder { builder.Params.HardSupplyRewardPeriods = append(builder.Params.HardSupplyRewardPeriods, period) @@ -76,6 +81,7 @@ func (builder IncentiveGenesisBuilder) WithInitializedSupplyRewardPeriod(period accumulationTimeForPeriod, ) + // TODO remove to better reflect real states builder.HardSupplyRewardState.MultiRewardIndexes = builder.HardSupplyRewardState.MultiRewardIndexes.With( period.CollateralType, newZeroRewardIndexesFromCoins(period.RewardsPerSecond...), @@ -88,6 +94,8 @@ func (builder IncentiveGenesisBuilder) WithSimpleSupplyRewardPeriod(ctype string return builder.WithInitializedSupplyRewardPeriod(builder.simpleRewardPeriod(ctype, rewardsPerSecond)) } +// WithInitializedDelegatorRewardPeriod sets the genesis time as the previous accumulation time for the specified period. +// This can be helpful in tests. With no prev time set, the first block accrues no rewards as it just sets the prev time to the current. func (builder IncentiveGenesisBuilder) WithInitializedDelegatorRewardPeriod(period types.MultiRewardPeriod) IncentiveGenesisBuilder { builder.Params.DelegatorRewardPeriods = append(builder.Params.DelegatorRewardPeriods, period) @@ -97,6 +105,7 @@ func (builder IncentiveGenesisBuilder) WithInitializedDelegatorRewardPeriod(peri accumulationTimeForPeriod, ) + // TODO remove to better reflect real states builder.DelegatorRewardState.MultiRewardIndexes = builder.DelegatorRewardState.MultiRewardIndexes.With( period.CollateralType, newZeroRewardIndexesFromCoins(period.RewardsPerSecond...), @@ -109,6 +118,8 @@ func (builder IncentiveGenesisBuilder) WithSimpleDelegatorRewardPeriod(ctype str return builder.WithInitializedDelegatorRewardPeriod(builder.simpleRewardPeriod(ctype, rewardsPerSecond)) } +// WithInitializedSwapRewardPeriod sets the genesis time as the previous accumulation time for the specified period. +// This can be helpful in tests. With no prev time set, the first block accrues no rewards as it just sets the prev time to the current. func (builder IncentiveGenesisBuilder) WithInitializedSwapRewardPeriod(period types.MultiRewardPeriod) IncentiveGenesisBuilder { builder.Params.SwapRewardPeriods = append(builder.Params.SwapRewardPeriods, period) @@ -118,11 +129,6 @@ func (builder IncentiveGenesisBuilder) WithInitializedSwapRewardPeriod(period ty accumulationTimeForPeriod, ) - builder.SwapRewardState.MultiRewardIndexes = builder.SwapRewardState.MultiRewardIndexes.With( - period.CollateralType, - newZeroRewardIndexesFromCoins(period.RewardsPerSecond...), - ) - return builder } @@ -130,6 +136,8 @@ func (builder IncentiveGenesisBuilder) WithSimpleSwapRewardPeriod(poolID string, return builder.WithInitializedSwapRewardPeriod(builder.simpleRewardPeriod(poolID, rewardsPerSecond)) } +// WithInitializedUSDXRewardPeriod sets the genesis time as the previous accumulation time for the specified period. +// This can be helpful in tests. With no prev time set, the first block accrues no rewards as it just sets the prev time to the current. func (builder IncentiveGenesisBuilder) WithInitializedUSDXRewardPeriod(period types.RewardPeriod) IncentiveGenesisBuilder { builder.Params.USDXMintingRewardPeriods = append(builder.Params.USDXMintingRewardPeriods, period) @@ -139,6 +147,7 @@ func (builder IncentiveGenesisBuilder) WithInitializedUSDXRewardPeriod(period ty accumulationTimeForPeriod, ) + // TODO remove to better reflect real states builder.USDXRewardState.MultiRewardIndexes = builder.USDXRewardState.MultiRewardIndexes.With( period.CollateralType, newZeroRewardIndexesFromCoins(period.RewardsPerSecond), diff --git a/x/incentive/types/accumulator.go b/x/incentive/types/accumulator.go index 80ce7e70..faf6f5c1 100644 --- a/x/incentive/types/accumulator.go +++ b/x/incentive/types/accumulator.go @@ -29,10 +29,12 @@ func NewAccumulator(previousAccrual time.Time, indexes RewardIndexes) *Accumulat // Rewards are not accrued for times outside of the start and end times of a reward period. // If a period ends before currentTime, the PreviousAccrualTime is shortened to the end time. This allows accumulate to be called sequentially on consecutive reward periods. // -// rewardSourceTotal is the total of all user reward sources. For example: total shares in a swap pool, total btcb supplied to hard, or total usdx borrowed from all bnb CDPs. -func (acc *Accumulator) Accumulate(period MultiRewardPeriod, rewardSourceTotal sdk.Dec, currentTime time.Time) { +// totalSourceShares is the sum of all users' source shares. For example:total btcb supplied to hard, total usdx borrowed from all bnb CDPs, or total shares in a swap pool. +func (acc *Accumulator) Accumulate(period MultiRewardPeriod, totalSourceShares sdk.Dec, currentTime time.Time) { + accumulationDuration := acc.getTimeElapsedWithinLimits(acc.PreviousAccumulationTime, currentTime, period.Start, period.End) - indexesIncrement := acc.calculateNewRewards(period.RewardsPerSecond, rewardSourceTotal, accumulationDuration) + + indexesIncrement := acc.calculateNewRewards(period.RewardsPerSecond, totalSourceShares, accumulationDuration) acc.Indexes = acc.Indexes.Add(indexesIncrement) acc.PreviousAccumulationTime = minTime(period.End, currentTime) @@ -40,7 +42,7 @@ func (acc *Accumulator) Accumulate(period MultiRewardPeriod, rewardSourceTotal s // getTimeElapsedWithinLimits returns the duration between start and end times, capped by min and max times. // If the start and end range is outside the min to max time range then zero duration is returned. -func (acc *Accumulator) getTimeElapsedWithinLimits(start, end, limitMin, limitMax time.Time) time.Duration { +func (*Accumulator) getTimeElapsedWithinLimits(start, end, limitMin, limitMax time.Time) time.Duration { if start.After(end) { panic(fmt.Sprintf("start time (%s) cannot be after end time (%s)", start, end)) } @@ -54,19 +56,25 @@ func (acc *Accumulator) getTimeElapsedWithinLimits(start, end, limitMin, limitMa return minTime(end, limitMax).Sub(maxTime(start, limitMin)) } -// calculateNewRewards calculates the amount to increase the global reward indexes for a given reward rate, duration, and source total. -// The total rewards to distribute in this block are given by reward rate * duration. This value divided by the source total to give -// total rewards per unit of source, which is what the indexes store. -// Note, duration is rounded to the nearest second to keep rewards calculation the same as in kava-7. -func (acc *Accumulator) calculateNewRewards(rewardsPerSecond sdk.Coins, rewardSourceTotal sdk.Dec, duration time.Duration) RewardIndexes { - if rewardSourceTotal.IsZero() { - // When the source total is zero, there is no users with deposits/borrows/delegations to pay out the current block's rewards to. +// calculateNewRewards calculates the amount to increase the global reward indexes by, for a given reward rate, duration, and number of source shares. +// The total rewards to distribute in this block are given by reward rate * duration. This value divided by the sum of all source shares to give +// total rewards per source share, which is what the indexes store. +// Note, duration is rounded to the nearest second to keep rewards calculation consistent with kava-7. +func (*Accumulator) calculateNewRewards(rewardsPerSecond sdk.Coins, totalSourceShares sdk.Dec, duration time.Duration) RewardIndexes { + if totalSourceShares.LTE(sdk.ZeroDec()) { + // When there is zero source shares, there is no users with deposits/borrows/delegations to pay out the current block's rewards to. // So drop the rewards and pay out nothing. return nil } durationSeconds := int64(math.RoundToEven(duration.Seconds())) + if durationSeconds <= 0 { + // If the duration is zero, there will be no increment. + // So return an empty increment instead of one full of zeros. + return nil + } increment := newRewardIndexesFromCoins(rewardsPerSecond) - return increment.Mul(sdk.NewDec(durationSeconds)).Quo(rewardSourceTotal) + increment = increment.Mul(sdk.NewDec(durationSeconds)).Quo(totalSourceShares) + return increment } // minTime returns the earliest of two times. diff --git a/x/incentive/types/accumulator_test.go b/x/incentive/types/accumulator_test.go index 9e4fd626..b770cc4b 100644 --- a/x/incentive/types/accumulator_test.go +++ b/x/incentive/types/accumulator_test.go @@ -104,7 +104,7 @@ func TestAccumulator(t *testing.T) { type args struct { rewardsPerSecond sdk.Coins duration time.Duration - rewardSourceTotal sdk.Dec + totalSourceShares sdk.Dec } testcases := []struct { name string @@ -116,7 +116,7 @@ func TestAccumulator(t *testing.T) { args: args{ rewardsPerSecond: cs(c("hard", 1000), c("swap", 100)), duration: 10 * time.Second, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), }, expected: RewardIndexes{ {CollateralType: "hard", RewardFactor: d("10")}, @@ -128,27 +128,39 @@ func TestAccumulator(t *testing.T) { args: args{ rewardsPerSecond: cs(c("hard", 1000)), duration: 10*time.Second + 500*time.Millisecond, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), }, expected: RewardIndexes{ {CollateralType: "hard", RewardFactor: d("10")}, }, }, { - name: "when duration is zero the rewards are zero", + name: "reward indexes have enough precision for extreme params", + args: args{ + rewardsPerSecond: cs(c("anydenom", 1)), // minimum possible rewards + duration: 1 * time.Second, // minimum possible duration (beyond zero as it's rounded) + totalSourceShares: d("100000000000000000"), // approximate shares in a $1B pool of 10^8 precision assets + }, + expected: RewardIndexes{ + // smallest reward amount over smallest accumulation duration does not go past 10^-18 decimal precision + {CollateralType: "anydenom", RewardFactor: d("0.000000000000000010")}, + }, + }, + { + name: "when duration is zero there is no rewards", args: args{ rewardsPerSecond: cs(c("hard", 1000)), duration: 0, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), }, - expected: RewardIndexes{{CollateralType: "hard", RewardFactor: d("0")}}, // TODO should this be nil? + expected: nil, }, { name: "when rewards per second are nil there is no rewards", args: args{ rewardsPerSecond: cs(), duration: 10 * time.Second, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), }, expected: nil, }, @@ -157,7 +169,7 @@ func TestAccumulator(t *testing.T) { args: args{ rewardsPerSecond: cs(c("hard", 1000)), duration: 10 * time.Second, - rewardSourceTotal: d("0"), + totalSourceShares: d("0"), }, expected: nil, }, @@ -166,7 +178,7 @@ func TestAccumulator(t *testing.T) { args: args{ rewardsPerSecond: cs(), duration: 0, - rewardSourceTotal: d("0"), + totalSourceShares: d("0"), }, expected: nil, }, @@ -175,7 +187,7 @@ func TestAccumulator(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { acc := &Accumulator{} - indexes := acc.calculateNewRewards(tc.args.rewardsPerSecond, tc.args.rewardSourceTotal, tc.args.duration) + indexes := acc.calculateNewRewards(tc.args.rewardsPerSecond, tc.args.totalSourceShares, tc.args.duration) require.Equal(t, tc.expected, indexes) }) @@ -185,7 +197,7 @@ func TestAccumulator(t *testing.T) { type args struct { accumulator Accumulator period MultiRewardPeriod - rewardSourceTotal sdk.Dec + totalSourceShares sdk.Dec currentTime time.Time } testcases := []struct { @@ -198,34 +210,40 @@ func TestAccumulator(t *testing.T) { args: args{ accumulator: Accumulator{ PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC), - Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("0.1")}}, + Indexes: RewardIndexes{ + {CollateralType: "hard", RewardFactor: d("0.1")}, + {CollateralType: "swap", RewardFactor: d("0.2")}, + }, }, period: MultiRewardPeriod{ Start: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC), End: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), RewardsPerSecond: cs(c("hard", 1000)), }, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), currentTime: time.Date(1998, 1, 1, 0, 0, 5, 0, time.UTC), }, expected: Accumulator{ PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 5, 0, time.UTC), - Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("5.1")}}, + Indexes: RewardIndexes{ + {CollateralType: "hard", RewardFactor: d("5.1")}, + {CollateralType: "swap", RewardFactor: d("0.2")}, + }, }, }, { - name: "nil reward indexes are treated as empty", + name: "empty reward indexes are added to correctly", args: args{ accumulator: Accumulator{ PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC), - Indexes: nil, + Indexes: RewardIndexes{}, }, period: MultiRewardPeriod{ Start: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC), End: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), RewardsPerSecond: cs(c("hard", 1000)), }, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), currentTime: time.Date(1998, 1, 1, 0, 0, 5, 0, time.UTC), }, expected: Accumulator{ @@ -233,6 +251,26 @@ func TestAccumulator(t *testing.T) { Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("5.0")}}, }, }, + { + name: "empty reward indexes are unchanged when there's no rewards", + args: args{ + accumulator: Accumulator{ + PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC), + Indexes: RewardIndexes{}, + }, + period: MultiRewardPeriod{ + Start: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + RewardsPerSecond: cs(), + }, + totalSourceShares: d("1000"), + currentTime: time.Date(1998, 1, 1, 0, 0, 5, 0, time.UTC), + }, + expected: Accumulator{ + PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 5, 0, time.UTC), + Indexes: RewardIndexes{}, + }, + }, { name: "when a period is enclosed within block the accumulation time is set to the period end time", args: args{ @@ -245,7 +283,7 @@ func TestAccumulator(t *testing.T) { End: time.Date(1998, 1, 1, 0, 0, 7, 0, time.UTC), RewardsPerSecond: cs(c("hard", 1000)), }, - rewardSourceTotal: d("1000"), + totalSourceShares: d("1000"), currentTime: time.Date(1998, 1, 1, 0, 0, 10, 0, time.UTC), }, expected: Accumulator{ @@ -253,11 +291,33 @@ func TestAccumulator(t *testing.T) { Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("2.1")}}, }, }, + { + name: "accumulation duration is capped at param start when previous stored time is in the distant past", + // This could happend in the default time value time.Time{} was accidentally stored, or if a reward period was + // removed from the params, then added back a long time later. + args: args{ + accumulator: Accumulator{ + PreviousAccumulationTime: time.Time{}, + Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("0.1")}}, + }, + period: MultiRewardPeriod{ + Start: time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + RewardsPerSecond: cs(c("hard", 1000)), + }, + totalSourceShares: d("1000"), + currentTime: time.Date(1998, 1, 1, 0, 0, 10, 0, time.UTC), + }, + expected: Accumulator{ + PreviousAccumulationTime: time.Date(1998, 1, 1, 0, 0, 10, 0, time.UTC), + Indexes: RewardIndexes{{CollateralType: "hard", RewardFactor: d("10.1")}}, + }, + }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - tc.args.accumulator.Accumulate(tc.args.period, tc.args.rewardSourceTotal, tc.args.currentTime) + tc.args.accumulator.Accumulate(tc.args.period, tc.args.totalSourceShares, tc.args.currentTime) require.Equal(t, tc.expected, tc.args.accumulator) }) } diff --git a/x/incentive/types/claims.go b/x/incentive/types/claims.go index d3e586ce..78c1048f 100644 --- a/x/incentive/types/claims.go +++ b/x/incentive/types/claims.go @@ -13,8 +13,6 @@ const ( HardLiquidityProviderClaimType = "hard_liquidity_provider" DelegatorClaimType = "delegator_claim" SwapClaimType = "swap" - - BondDenom = "ukava" ) // Claim is an interface for handling common claim actions diff --git a/x/incentive/types/keys.go b/x/incentive/types/keys.go index 717bcc96..6c61b670 100644 --- a/x/incentive/types/keys.go +++ b/x/incentive/types/keys.go @@ -33,6 +33,4 @@ var ( SwapClaimKeyPrefix = []byte{0x12} // prefix for keys that store swap claims SwapRewardIndexesKeyPrefix = []byte{0x13} // prefix for key that stores swap reward indexes PreviousSwapRewardAccrualTimeKeyPrefix = []byte{0x14} // prefix for key that stores the previous time swap rewards accrued - - USDXMintingRewardDenom = "ukava" ) diff --git a/x/incentive/types/params.go b/x/incentive/types/params.go index 16c02b55..5b9bc578 100644 --- a/x/incentive/types/params.go +++ b/x/incentive/types/params.go @@ -9,10 +9,8 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/cosmos/cosmos-sdk/x/params" - tmtime "github.com/tendermint/tendermint/types/time" - cdptypes "github.com/kava-labs/kava/x/cdp/types" kavadistTypes "github.com/kava-labs/kava/x/kavadist/types" ) @@ -32,14 +30,17 @@ var ( KeySwapRewardPeriods = []byte("SwapRewardPeriods") KeyClaimEnd = []byte("ClaimEnd") KeyMultipliers = []byte("ClaimMultipliers") - DefaultActive = false - DefaultRewardPeriods = RewardPeriods{} - DefaultMultiRewardPeriods = MultiRewardPeriods{} - DefaultMultipliers = Multipliers{} - DefaultClaimEnd = tmtime.Canonical(time.Unix(1, 0)) - GovDenom = cdptypes.DefaultGovDenom - PrincipalDenom = "usdx" - IncentiveMacc = kavadistTypes.ModuleName + + DefaultActive = false + DefaultRewardPeriods = RewardPeriods{} + DefaultMultiRewardPeriods = MultiRewardPeriods{} + DefaultMultipliers = Multipliers{} + DefaultClaimEnd = tmtime.Canonical(time.Unix(1, 0)) + + BondDenom = "ukava" + USDXMintingRewardDenom = "ukava" + + IncentiveMacc = kavadistTypes.ModuleName ) // Params governance parameters for the incentive module @@ -210,6 +211,18 @@ func NewRewardPeriod(active bool, collateralType string, start time.Time, end ti } } +// NewMultiRewardPeriodFromRewardPeriod converts a RewardPeriod into a MultiRewardPeriod. +// It's useful for compatibility between single and multi denom rewards. +func NewMultiRewardPeriodFromRewardPeriod(period RewardPeriod) MultiRewardPeriod { + return NewMultiRewardPeriod( + period.Active, + period.CollateralType, + period.Start, + period.End, + sdk.NewCoins(period.RewardsPerSecond), + ) +} + // Validate performs a basic check of a RewardPeriod fields. func (rp RewardPeriod) Validate() error { if rp.Start.Unix() <= 0 { @@ -219,8 +232,12 @@ func (rp RewardPeriod) Validate() error { return errors.New("reward period end time cannot be 0") } if rp.Start.After(rp.End) { + // This is needed to ensure that the begin blocker accumulation does not panic. return fmt.Errorf("end period time %s cannot be before start time %s", rp.End, rp.Start) } + if rp.RewardsPerSecond.Denom != USDXMintingRewardDenom { + return fmt.Errorf("reward denom must be %s, got: %s", USDXMintingRewardDenom, rp.RewardsPerSecond.Denom) + } if !rp.RewardsPerSecond.IsValid() { return fmt.Errorf("invalid reward amount: %s", rp.RewardsPerSecond) } @@ -291,6 +308,7 @@ func (mrp MultiRewardPeriod) Validate() error { return errors.New("reward period end time cannot be 0") } if mrp.Start.After(mrp.End) { + // This is needed to ensure that the begin blocker accumulation does not panic. return fmt.Errorf("end period time %s cannot be before start time %s", mrp.End, mrp.Start) } if !mrp.RewardsPerSecond.IsValid() { diff --git a/x/incentive/types/params_test.go b/x/incentive/types/params_test.go index b21b628e..91240b25 100644 --- a/x/incentive/types/params_test.go +++ b/x/incentive/types/params_test.go @@ -1,6 +1,7 @@ package types_test import ( + "fmt" "testing" "time" @@ -31,6 +32,20 @@ var rewardMultiPeriodWithInvalidRewardsPerSecond = types.NewMultiRewardPeriod( time.Date(2024, 10, 15, 14, 0, 0, 0, time.UTC), sdk.Coins{sdk.Coin{Denom: "INVALID!@#😫", Amount: sdk.ZeroInt()}}, ) +var validMultiRewardPeriod = types.NewMultiRewardPeriod( + true, + "bnb", + time.Date(2020, 10, 15, 14, 0, 0, 0, time.UTC), + time.Date(2024, 10, 15, 14, 0, 0, 0, time.UTC), + sdk.NewCoins(sdk.NewInt64Coin("swap", 1e9)), +) +var validRewardPeriod = types.NewRewardPeriod( + true, + "bnb-a", + time.Date(2020, 10, 15, 14, 0, 0, 0, time.UTC), + time.Date(2024, 10, 15, 14, 0, 0, 0, time.UTC), + sdk.NewInt64Coin(types.USDXMintingRewardDenom, 1e9), +) func (suite *ParamTestSuite) TestParamValidation() { type errArgs struct { @@ -93,7 +108,7 @@ func (suite *ParamTestSuite) TestParamValidation() { }, errArgs{ expectPass: false, - contains: "invalid reward amount", + contains: fmt.Sprintf("reward denom must be %s", types.USDXMintingRewardDenom), }, }, { @@ -176,6 +191,120 @@ func (suite *ParamTestSuite) TestParamValidation() { } } +func (suite *ParamTestSuite) TestRewardPeriods() { + suite.Run("Validate", func() { + type err struct { + pass bool + contains string + } + testCases := []struct { + name string + periods types.RewardPeriods + expect err + }{ + { + name: "single period is valid", + periods: types.RewardPeriods{ + validRewardPeriod, + }, + expect: err{ + pass: true, + }, + }, + { + name: "duplicated reward period is invalid", + periods: types.RewardPeriods{ + validRewardPeriod, + validRewardPeriod, + }, + expect: err{ + contains: "duplicated reward period", + }, + }, + { + name: "invalid reward denom is invalid", + periods: types.RewardPeriods{ + types.NewRewardPeriod( + true, + "bnb-a", + time.Date(2020, 10, 15, 14, 0, 0, 0, time.UTC), + time.Date(2024, 10, 15, 14, 0, 0, 0, time.UTC), + sdk.NewInt64Coin("hard", 1e9), + ), + }, + expect: err{ + contains: fmt.Sprintf("reward denom must be %s", types.USDXMintingRewardDenom), + }, + }, + } + for _, tc := range testCases { + + err := tc.periods.Validate() + + if tc.expect.pass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Contains(err.Error(), tc.expect.contains) + } + } + }) +} + +func (suite *ParamTestSuite) TestMultiRewardPeriods() { + suite.Run("Validate", func() { + type err struct { + pass bool + contains string + } + testCases := []struct { + name string + periods types.MultiRewardPeriods + expect err + }{ + { + name: "single period is valid", + periods: types.MultiRewardPeriods{ + validMultiRewardPeriod, + }, + expect: err{ + pass: true, + }, + }, + { + name: "duplicated reward period is invalid", + periods: types.MultiRewardPeriods{ + validMultiRewardPeriod, + validMultiRewardPeriod, + }, + expect: err{ + contains: "duplicated reward period", + }, + }, + { + name: "invalid reward period is invalid", + periods: types.MultiRewardPeriods{ + rewardMultiPeriodWithInvalidRewardsPerSecond, + }, + expect: err{ + contains: "invalid reward amount", + }, + }, + } + for _, tc := range testCases { + + err := tc.periods.Validate() + + if tc.expect.pass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Contains(err.Error(), tc.expect.contains) + } + } + }) +} + func TestParamTestSuite(t *testing.T) { suite.Run(t, new(ParamTestSuite)) } diff --git a/x/incentive/types/period.go b/x/incentive/types/period.go deleted file mode 100644 index c2894750..00000000 --- a/x/incentive/types/period.go +++ /dev/null @@ -1,11 +0,0 @@ -package types - -import ( - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/x/auth/vesting" -) - -// NewPeriod returns a new vesting period -func NewPeriod(amount sdk.Coins, length int64) vesting.Period { - return vesting.Period{Amount: amount, Length: length} -} diff --git a/x/incentive/types/sdk.go b/x/incentive/types/sdk.go index 4923d821..276ce6b8 100644 --- a/x/incentive/types/sdk.go +++ b/x/incentive/types/sdk.go @@ -5,6 +5,11 @@ import ( "github.com/cosmos/cosmos-sdk/x/auth/vesting" ) +// NewPeriod returns a new vesting period +func NewPeriod(amount sdk.Coins, length int64) vesting.Period { + return vesting.Period{Amount: amount, Length: length} +} + // GetTotalVestingPeriodLength returns the summed length of all vesting periods func GetTotalVestingPeriodLength(periods vesting.Periods) int64 { length := int64(0)