diff --git a/x/incentive/keeper/integration_test.go b/x/incentive/keeper/integration_test.go index cfbb974acf..75655e4dd1 100644 --- a/x/incentive/keeper/integration_test.go +++ b/x/incentive/keeper/integration_test.go @@ -7,6 +7,7 @@ import ( "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/incentive" "github.com/kava-labs/kava/x/pricefeed" ) @@ -141,3 +142,78 @@ func NewPricefeedGenStateMulti() app.GenesisState { } return app.GenesisState{pricefeed.ModuleName: pricefeed.ModuleCdc.MustMarshalJSON(pfGenesis)} } + +func NewIncentiveGenState(previousAccumTime, endTime time.Time, rewardPeriods ...incentive.RewardPeriod) app.GenesisState { + var accumulationTimes incentive.GenesisAccumulationTimes + for _, rp := range rewardPeriods { + accumulationTimes = append( + accumulationTimes, + incentive.NewGenesisAccumulationTime( + rp.CollateralType, + previousAccumTime, + sdk.ZeroDec(), + ), + ) + } + genesis := incentive.NewGenesisState( + incentive.NewParams( + rewardPeriods, + incentive.Multipliers{ + incentive.NewMultiplier(incentive.Small, 1, d("0.25")), + incentive.NewMultiplier(incentive.Large, 12, d("1.0")), + }, + endTime, + ), + accumulationTimes, + incentive.USDXMintingClaims{}, + ) + return app.GenesisState{incentive.ModuleName: incentive.ModuleCdc.MustMarshalJSON(genesis)} +} + +func NewCDPGenStateHighInterest() app.GenesisState { + oneYear := time.Hour * 24 * 365 + cdpGenesis := cdp.GenesisState{ + Params: cdp.Params{ + GlobalDebtLimit: sdk.NewInt64Coin("usdx", 2000000000000), + SurplusAuctionThreshold: cdp.DefaultSurplusThreshold, + SurplusAuctionLot: cdp.DefaultSurplusLot, + DebtAuctionThreshold: cdp.DefaultDebtThreshold, + DebtAuctionLot: cdp.DefaultDebtLot, + SavingsDistributionFrequency: oneYear * 100, // never run savings distribution + CollateralParams: cdp.CollateralParams{ + { + Denom: "bnb", + Type: "bnb-a", + LiquidationRatio: sdk.MustNewDecFromStr("1.5"), + DebtLimit: sdk.NewInt64Coin("usdx", 500000000000), + StabilityFee: sdk.MustNewDecFromStr("1.000000051034942716"), // 500% APR + LiquidationPenalty: d("0.05"), + AuctionSize: i(50000000000), + Prefix: 0x22, + SpotMarketID: "bnb:usd", + LiquidationMarketID: "bnb:usd", + ConversionFactor: i(8), + }, + }, + DebtParam: cdp.DebtParam{ + Denom: "usdx", + ReferenceAsset: "usd", + ConversionFactor: i(6), + DebtFloor: i(10000000), + SavingsRate: d("0.95"), + }, + }, + StartingCdpID: cdp.DefaultCdpStartingID, + DebtDenom: cdp.DefaultDebtDenom, + GovDenom: cdp.DefaultGovDenom, + CDPs: cdp.CDPs{}, + PreviousDistributionTime: cdp.DefaultPreviousDistributionTime, + PreviousAccumulationTimes: cdp.GenesisAccumulationTimes{ + cdp.NewGenesisAccumulationTime("bnb-a", time.Time{}, sdk.OneDec()), + }, + TotalPrincipals: cdp.GenesisTotalPrincipals{ + cdp.NewGenesisTotalPrincipal("bnb-a", sdk.ZeroInt()), + }, + } + return app.GenesisState{cdp.ModuleName: cdp.ModuleCdc.MustMarshalJSON(cdpGenesis)} +} diff --git a/x/incentive/keeper/rewards.go b/x/incentive/keeper/rewards.go index f8edc4cdf4..0e5b27be20 100644 --- a/x/incentive/keeper/rewards.go +++ b/x/incentive/keeper/rewards.go @@ -35,7 +35,12 @@ func (k Keeper) AccumulateRewards(ctx sdk.Context, rewardPeriod types.RewardPeri return nil } newRewards := timeElapsed.Mul(rewardPeriod.RewardsPerSecond.Amount) - rewardFactor := newRewards.ToDec().Quo(totalPrincipal) + cdpFactor, found := k.cdpKeeper.GetInterestFactor(ctx, rewardPeriod.CollateralType) + if !found { + k.SetPreviousAccrualTime(ctx, rewardPeriod.CollateralType, ctx.BlockTime()) + return nil + } + rewardFactor := newRewards.ToDec().Mul(cdpFactor).Quo(totalPrincipal) previousRewardFactor, found := k.GetRewardFactor(ctx, rewardPeriod.CollateralType) if !found { @@ -110,7 +115,7 @@ func (k Keeper) SynchronizeReward(ctx sdk.Context, cdp cdptypes.CDP) { return } claim.RewardIndexes[index].RewardFactor = globalRewardFactor - newRewardsAmount := rewardsAccumulatedFactor.Mul(cdp.GetTotalPrincipal().Amount.ToDec()).RoundInt() + newRewardsAmount := cdp.GetTotalPrincipal().Amount.ToDec().Quo(cdp.InterestFactor).Mul(rewardsAccumulatedFactor).RoundInt() if newRewardsAmount.IsZero() { k.SetClaim(ctx, claim) return diff --git a/x/incentive/keeper/rewards_test.go b/x/incentive/keeper/rewards_test.go index 8adb4dcbf5..2cad11544d 100644 --- a/x/incentive/keeper/rewards_test.go +++ b/x/incentive/keeper/rewards_test.go @@ -2,14 +2,17 @@ package keeper_test import ( "fmt" + "testing" "time" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" abci "github.com/tendermint/tendermint/abci/types" tmtime "github.com/tendermint/tendermint/types/time" "github.com/kava-labs/kava/app" + cdpkeeper "github.com/kava-labs/kava/x/cdp/keeper" cdptypes "github.com/kava-labs/kava/x/cdp/types" "github.com/kava-labs/kava/x/incentive/types" ) @@ -200,6 +203,101 @@ func (suite *KeeperTestSuite) TestSyncRewards() { } +func TestRewardCalculation(t *testing.T) { + + // Test Params + ctype := "bnb-a" + initialTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + rewardsPerSecond := c("ukava", 122_354) + initialCollateral := c("bnb", 10_000_000_000) + initialPrincipal := c("usdx", 100_000_000) + oneYear := time.Hour * 24 * 365 + rewardPeriod := types.NewRewardPeriod( + true, + ctype, + initialTime, + initialTime.Add(4*oneYear), + rewardsPerSecond, + ) + + // Setup app and module params + _, addrs := app.GeneratePrivKeyAddressPairs(5) + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: initialTime}) + tApp.InitializeFromGenesisStates( + app.NewAuthGenState(addrs[:1], []sdk.Coins{cs(initialCollateral)}), + NewPricefeedGenStateMulti(), + NewCDPGenStateHighInterest(), + NewIncentiveGenState(initialTime, initialTime.Add(oneYear), rewardPeriod), + ) + + // Create a CDP + cdpKeeper := tApp.GetCDPKeeper() + err := cdpKeeper.AddCdp( + ctx, + addrs[0], + initialCollateral, + initialPrincipal, + ctype, + ) + require.NoError(t, err) + + // Calculate expected cdp reward using iteration + + // Use 10 blocks, each a very long 630720s, to total 6307200s or 1/5th of a year + // The cdp stability fee is set to the max value 500%, so this time ensures the debt increases a significant amount (doubles) + // High stability fees increase the chance of catching calculation bugs. + blockTimes := newRepeatingSliceInt(630720, 10) + expectedCDPReward := sdk.ZeroDec() //c(rewardPeriod.RewardsPerSecond.Denom, 0) + for _, bt := range blockTimes { + ctx = ctx.WithBlockTime(ctx.BlockTime().Add(time.Duration(int(time.Second) * bt))) + + // run cdp and incentive begin blockers to update factors + tApp.BeginBlocker(ctx, abci.RequestBeginBlock{}) + + // calculate expected cdp reward + cdpBlockReward, err := calculateCDPBlockReward(ctx, cdpKeeper, addrs[0], ctype, sdk.NewInt(int64(bt)), rewardPeriod) + require.NoError(t, err) + expectedCDPReward = expectedCDPReward.Add(cdpBlockReward) + } + + // calculate cdp reward using factor + cdp, found := cdpKeeper.GetCdpByOwnerAndCollateralType(ctx, addrs[0], ctype) + require.True(t, found) + incentiveKeeper := tApp.GetIncentiveKeeper() + require.NotPanics(t, func() { + incentiveKeeper.SynchronizeReward(ctx, cdp) + }) + claim, found := incentiveKeeper.GetClaim(ctx, addrs[0]) + require.True(t, found) + + // Compare two methods of calculation + relativeError := expectedCDPReward.Sub(claim.Reward.Amount.ToDec()).Quo(expectedCDPReward).Abs() + maxError := d("0.0001") + require.Truef(t, relativeError.LT(maxError), + "percent diff %s > %s , expected: %s, actual %s,", relativeError, maxError, expectedCDPReward, claim.Reward.Amount, + ) +} + +// calculateCDPBlockReward computes the reward that should be distributed to a cdp for the current block. +func calculateCDPBlockReward(ctx sdk.Context, cdpKeeper cdpkeeper.Keeper, owner sdk.AccAddress, ctype string, timeElapsed sdk.Int, rewardPeriod types.RewardPeriod) (sdk.Dec, error) { + // Calculate total rewards to distribute this block + newRewards := timeElapsed.Mul(rewardPeriod.RewardsPerSecond.Amount) + + // Calculate cdp's share of total debt + totalPrincipal := cdpKeeper.GetTotalPrincipal(ctx, ctype, types.PrincipalDenom).ToDec() + // cdpDebt + cdp, found := cdpKeeper.GetCdpByOwnerAndCollateralType(ctx, owner, ctype) + if !found { + return sdk.Dec{}, fmt.Errorf("couldn't find cdp for owner '%s' and collateral type '%s'", owner, ctype) + } + accumulatedInterest := cdpKeeper.CalculateNewInterest(ctx, cdp) + cdpDebt := cdp.Principal.Add(cdp.AccumulatedFees).Add(accumulatedInterest).Amount + + // Calculate cdp's reward + return newRewards.Mul(cdpDebt).ToDec().Quo(totalPrincipal), nil +} + func (suite *KeeperTestSuite) SetupWithCDPGenState() { tApp := app.NewTestApp() ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) @@ -214,3 +312,12 @@ func (suite *KeeperTestSuite) SetupWithCDPGenState() { suite.keeper = keeper suite.addrs = addrs } + +// newRepeatingSliceInt creates a slice of the specified length containing a single repeating element. +func newRepeatingSliceInt(element int, length int) []int { + slice := make([]int, length) + for i := 0; i < length; i++ { + slice[i] = element + } + return slice +} diff --git a/x/incentive/types/expected_keepers.go b/x/incentive/types/expected_keepers.go index 58d635f787..abc4367371 100644 --- a/x/incentive/types/expected_keepers.go +++ b/x/incentive/types/expected_keepers.go @@ -17,9 +17,9 @@ type SupplyKeeper interface { // CdpKeeper defines the expected cdp keeper for interacting with cdps type CdpKeeper interface { - IterateCdpsByCollateralType(ctx sdk.Context, collateralType string, cb func(cdp cdptypes.CDP) (stop bool)) GetTotalPrincipal(ctx sdk.Context, collateralType string, principalDenom string) (total sdk.Int) GetCdpByOwnerAndCollateralType(ctx sdk.Context, owner sdk.AccAddress, collateralType string) (cdptypes.CDP, bool) + GetInterestFactor(ctx sdk.Context, collateralType string) (sdk.Dec, bool) } // AccountKeeper defines the expected keeper interface for interacting with account