package testutil import ( "fmt" "math" "math/rand" "time" "github.com/stretchr/testify/suite" tmproto "github.com/tendermint/tendermint/proto/tendermint/types" sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/community" "github.com/kava-labs/kava/x/community/keeper" "github.com/kava-labs/kava/x/community/types" ) // StakingRewardsTestSuite tests staking rewards per second logic type stakingRewardsTestSuite struct { suite.Suite App app.TestApp Keeper keeper.Keeper testFunc testFunc } func NewStakingRewardsTestSuite(tf testFunc) *stakingRewardsTestSuite { suite := &stakingRewardsTestSuite{} suite.testFunc = tf return suite } // The default state used by each test func (suite *stakingRewardsTestSuite) SetupTest() { app.SetSDKConfig() tApp := app.NewTestApp() tApp.InitializeFromGenesisStates() suite.App = tApp suite.Keeper = suite.App.GetCommunityKeeper() } func (suite *stakingRewardsTestSuite) TestStakingRewards() { testCases := []struct { // name of subtest name string // block time of first block periodStart time.Time // block time of last block periodEnd time.Time // block time n will be periodStart + rand(range_min...range_max)*(n-1) up to periodEnd blockTimeRangeMin float64 blockTimeRangeMax float64 // rewards per second to set in state rewardsPerSecond sdkmath.LegacyDec // the amount of ukava to mint and transfer to the community pool // to use to pay for rewards communityPoolFunds sdkmath.Int // how many total rewards are expected to be accumulated in ukava expectedRewardsTotal sdkmath.Int }{ // ** These take a long time to run ** //{ // name: "one year with 0.5 to 1 second block times", // periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), // periodEnd: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // blockTimeRangeMin: 0.5, // blockTimeRangeMax: 1, // rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("1585489.599188229325215626"), // expectedRewardsTotal: sdkmath.NewInt(49999999999999), // 50 million KAVA per year //}, //{ // name: "one year with 5.5 to 6.5 second blocktimes", // periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), // periodEnd: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // blockTimeRangeMin: 5.5, // blockTimeRangeMax: 6.5, // rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("1585489.599188229325215626"), // 50 million kava per year // communityPoolFunds: sdkmath.NewInt(50000000000000), // expectedRewardsTotal: sdkmath.NewInt(49999999999999), // truncation results in 1 ukava error //}, // // // One Day of blocks with different block time variations // // { name: "one day with sub-second block times and 50 million KAVA per year", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 0.1, blockTimeRangeMax: 1, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("1585489.599188229325215626"), // 50 million kava per year communityPoolFunds: sdkmath.NewInt(200000000000), expectedRewardsTotal: sdkmath.NewInt(136986301369), // 50 million / 365 days - 1 ukava }, { name: "one day with 5.5 to 6.5 second block times and 50 million KAVA per year", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.5, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("1585489.599188229325215626"), // 50 million kava per year communityPoolFunds: sdkmath.NewInt(200000000000), expectedRewardsTotal: sdkmath.NewInt(136986301369), // 50 million / 365 days - 1 ukava }, // // // Total time span under 1 second // // { name: "single 6.9 second time span and 25 million KAVA per year", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 1, 0, 0, 6, 900000000, time.UTC), blockTimeRangeMin: 10, // forces only two blocks -- one time span blockTimeRangeMax: 10, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("792744.799594114662607813"), // 25 million kava per year communityPoolFunds: sdkmath.NewInt(10000000), expectedRewardsTotal: sdkmath.NewInt(5469939), // per second rate * 6.9 }, { name: "multiple blocks across sub-second time span nd 10 million KAVA per year", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 1, 0, 0, 0, 800000000, time.UTC), blockTimeRangeMin: 0.1, // multiple blocks in a sub-second time span blockTimeRangeMax: 0.2, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("317097.919837645865043125"), // 10 million kava per year communityPoolFunds: sdkmath.NewInt(300000), expectedRewardsTotal: sdkmath.NewInt(253678), // per second rate * 0.8 }, // // // Variations of community pool balance // // { name: "community pool exact funds -- should spend community to zero and not panic", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.2, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("317097.919837645865043125"), // 10 million kava per year communityPoolFunds: sdkmath.NewInt(27397260273), expectedRewardsTotal: sdkmath.NewInt(27397260273), }, { name: "community pool under funded -- should spend community pool to down to zero and not panic", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.5, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("1585489.599188229325215626"), // 25 million kava per year communityPoolFunds: sdkmath.NewInt(100000000000), // under funded expectedRewardsTotal: sdkmath.NewInt(100000000000), // rewards max is the community pool balance }, { name: "community pool no funds -- should pay zero rewards and not panic", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.5, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("792744.799594114662607813"), // 25 million kava per year communityPoolFunds: sdkmath.NewInt(0), expectedRewardsTotal: sdkmath.NewInt(0), }, // // // Disabled // // { name: "zero rewards per second results in zero rewards paid", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.5, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("0.000000000000000000"), // 25 million kava per year communityPoolFunds: sdkmath.NewInt(100000000000000), expectedRewardsTotal: sdkmath.NewInt(0), }, // // // Test underlying calculations are safe and overflow/underflow bounds are reasonable // // { name: "does not overflow with extremely large per second value and extremely large single block durations", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2033, 1, 1, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 315619200, // a single 10 year long block in seconds (w/ 3 leap years) blockTimeRangeMax: 315619200, // a single 10 year long block in seconds (w/ 3 leap years) rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("100000000000000000000000000.000000000000000000"), // 100 million kava per second in 18 decimal form communityPoolFunds: newIntFromString("40000000000000000000000000000000000"), expectedRewardsTotal: newIntFromString("31561920000000000000000000000000000"), // 10 years worth of rewards (with three leap years) }, { name: "able to accumulate decimal ukava units across blocks", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 5.5, blockTimeRangeMax: 6.5, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("0.100000000000000000"), // blocks are not long enough to accumulate a single ukava with this rate communityPoolFunds: sdkmath.NewInt(10000), expectedRewardsTotal: sdkmath.NewInt(8640), }, { name: "down to 1 ukava per year can be accumulated -- we are safe from underflow at reasonably small values", periodStart: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), periodEnd: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), blockTimeRangeMin: 60, // large block times speed up this test case blockTimeRangeMax: 120, rewardsPerSecond: sdkmath.LegacyMustNewDecFromStr("0.000000031709791984"), communityPoolFunds: sdkmath.NewInt(1), expectedRewardsTotal: sdkmath.NewInt(1), }, } for _, tc := range testCases { suite.Run(tc.name, func() { suite.SetupTest() // keepers keeper := suite.Keeper accountKeeper := suite.App.GetAccountKeeper() bankKeeper := suite.App.GetBankKeeper() // initial context at height 1 height := int64(1) blockTime := tc.periodStart ctx := suite.App.NewContext(true, tmproto.Header{Height: height, Time: blockTime}) // ensure community pool balance matches the test expectations poolAcc := accountKeeper.GetModuleAccount(ctx, types.ModuleName) // community pool balance should start at zero suite.Require().True(bankKeeper.GetBalance(ctx, poolAcc.GetAddress(), "ukava").Amount.IsZero(), "expected community pool to start with zero coins in test genesis") // fund withexact amount from test case suite.App.FundAccount(ctx, poolAcc.GetAddress(), sdk.NewCoins(sdk.NewCoin("ukava", tc.communityPoolFunds))) // get starting balance of fee collector to substract later in case this is non-zero in genesis feeCollectorAcc := accountKeeper.GetModuleAccount(ctx, authtypes.FeeCollectorName) initialFeeCollectorBalance := bankKeeper.GetBalance(ctx, feeCollectorAcc.GetAddress(), "ukava").Amount // set rewards per second in state params, found := keeper.GetParams(ctx) suite.Require().True(found) params.StakingRewardsPerSecond = tc.rewardsPerSecond keeper.SetParams(ctx, params) stakingRewardEvents := sdk.Events{} for { // run community begin blocker logic suite.testFunc(ctx, keeper) // accumulate event rewards from events stakingRewardEvents = append(stakingRewardEvents, filterStakingRewardEvents(ctx.EventManager().Events())...) // exit loop if we are at last block if blockTime.Equal(tc.periodEnd) { break } // create random block duration in nanoseconds randomBlockDurationInSeconds := tc.blockTimeRangeMin + rand.Float64()*(tc.blockTimeRangeMax-tc.blockTimeRangeMin) nextBlockDuration := time.Duration(randomBlockDurationInSeconds * math.Pow10(9)) // move to next block by incrementing height, adding random duration, and settings new context height++ blockTime = blockTime.Add(nextBlockDuration) // set last block to exact end of period if we go past if blockTime.After(tc.periodEnd) { blockTime = tc.periodEnd } ctx = suite.App.NewContext(true, tmproto.Header{Height: height, Time: blockTime}) } endingFeeCollectorBalance := bankKeeper.GetBalance(ctx, feeCollectorAcc.GetAddress(), "ukava").Amount feeCollectorBalanceAdded := endingFeeCollectorBalance.Sub(initialFeeCollectorBalance) // assert fee pool was payed the correct rewards suite.Equal(tc.expectedRewardsTotal.String(), feeCollectorBalanceAdded.String(), "expected fee collector balance to match") if tc.expectedRewardsTotal.IsZero() { suite.Equal(0, len(stakingRewardEvents), "expected no events to be emitted") } else { // we add up all reward coin events eventCoins := getRewardCoinsFromEvents(stakingRewardEvents) // assert events emitted match expected rewards suite.Equal( tc.expectedRewardsTotal.String(), eventCoins.AmountOf("ukava").String(), "expected event coins to match", ) } // assert the community pool deducted the same amount expectedCommunityPoolBalance := tc.communityPoolFunds.Sub(tc.expectedRewardsTotal) actualCommunityPoolBalance := bankKeeper.GetBalance(ctx, poolAcc.GetAddress(), "ukava").Amount suite.Equal(expectedCommunityPoolBalance.String(), actualCommunityPoolBalance.String(), "expected community pool balance to match") }) } } func (suite *stakingRewardsTestSuite) TestStakingRewardsDoNotAccumulateWhenPoolIsDrained() { app := suite.App keeper := suite.Keeper accountKeeper := suite.App.GetAccountKeeper() bankKeeper := suite.App.GetBankKeeper() // first block blockTime := time.Now() ctx := app.NewContext(true, tmproto.Header{Height: 1, Time: blockTime}) poolAcc := accountKeeper.GetModuleAccount(ctx, types.ModuleName) feeCollectorAcc := accountKeeper.GetModuleAccount(ctx, authtypes.FeeCollectorName) // set state to pay staking rewards params, _ := keeper.GetParams(ctx) // we set a decimal amount that ensures after 10 seconds we overspend the community pool // with enough truncation error that we would have an ending balance of 20.000001 if it was // carried over after the pool run out of funds params.StakingRewardsPerSecond = sdkmath.LegacyMustNewDecFromStr("1000000.099999999999999999") // > 1 KAVA per second keeper.SetParams(ctx, params) // fund community pool account app.FundAccount(ctx, poolAcc.GetAddress(), sdk.NewCoins(sdk.NewCoin("ukava", sdkmath.NewInt(10000000)))) // 10 KAVA initialFeeCollectorBalance := bankKeeper.GetBalance(ctx, feeCollectorAcc.GetAddress(), "ukava").Amount // run first block (no rewards hapeen on first block) community.BeginBlocker(ctx, keeper) // run second block 10 seconds in future and spend all community pool rewards blockTime = blockTime.Add(10 * time.Second) ctx = app.NewContext(true, tmproto.Header{Height: 2, Time: blockTime}) community.BeginBlocker(ctx, keeper) // run third block 10 seconds in future which no rewards will be paid blockTime = blockTime.Add(10 * time.Second) ctx = app.NewContext(true, tmproto.Header{Height: 3, Time: blockTime}) community.BeginBlocker(ctx, keeper) // run fourth block 10 seconds in future which no rewards will be paid blockTime = blockTime.Add(10 * time.Second) ctx = app.NewContext(true, tmproto.Header{Height: 4, Time: blockTime}) community.BeginBlocker(ctx, keeper) // refund the community pool with 100 KAVA -- plenty of funds app.FundAccount(ctx, poolAcc.GetAddress(), sdk.NewCoins(sdk.NewCoin("ukava", sdkmath.NewInt(100000000)))) // 100 KAVA // run fifth block 10 seconds in future which no rewards will be paid blockTime = blockTime.Add(10 * time.Second) ctx = app.NewContext(true, tmproto.Header{Height: 5, Time: blockTime}) community.BeginBlocker(ctx, keeper) // assert that only 20 total KAVA has been distributed in rewards // and blocks where community pool had d rewards := bankKeeper.GetBalance(ctx, feeCollectorAcc.GetAddress(), "ukava").Amount.Sub(initialFeeCollectorBalance) suite.Require().Equal(sdkmath.NewInt(20000000).String(), rewards.String()) } func (suite *stakingRewardsTestSuite) TestPanicsOnMissingParameters() { suite.SetupTest() ctx := suite.App.NewContext(true, tmproto.Header{Height: 1, Time: time.Now()}) store := ctx.KVStore(suite.App.GetKVStoreKey(types.StoreKey)) store.Delete(types.ParamsKey) suite.PanicsWithValue("invalid state: module parameters not found", func() { suite.testFunc(ctx, suite.Keeper) }) } // newIntFromString returns a new sdkmath.Int from a string func newIntFromString(str string) sdkmath.Int { num, ok := sdkmath.NewIntFromString(str) if !ok { panic(fmt.Sprintf("overflow creating Int from %s", str)) } return num } func filterStakingRewardEvents(events sdk.Events) (rewardEvents sdk.Events) { for _, event := range events { if event.Type == types.EventTypeStakingRewardsPaid { rewardEvents = append(rewardEvents, event) } } return } func getRewardCoinsFromEvents(events sdk.Events) sdk.Coins { coins := sdk.NewCoins() for _, event := range events { if event.Type == types.EventTypeStakingRewardsPaid { rewards, err := sdk.ParseCoinNormalized(string(event.Attributes[0].Value)) if err != nil { panic(err) } coins = coins.Add(rewards) } } return coins }