From 6ef9bab67df425f53c57abd1857ef77dc849ca1c Mon Sep 17 00:00:00 2001 From: Derrick Lee Date: Wed, 28 Sep 2022 13:20:01 -0700 Subject: [PATCH] Add liquid staking reward redistribution via incentive (#1308) * wip Add claim * Add distr keeper and claiming * Add claim test * Update claim test with failures * wip Add staking rewards * -S Fix savings to earn incentive methods * Use a single accural time for all earn incentives * Add additional required liquid methods * Update genesis to only include 1 accrual time for earn * Revert "Update genesis to only include 1 accrual time for earn" This reverts commit cc7e35347298681c0c8a4a0b9bf9b9b296c25531. * Revert "Use a single accural time for all earn incentives" This reverts commit aeb49c4622d4e3d99dc6421c8830932b1b546be9. * Update tests with incentive distribution * Add earn to incentive rewards query * add earn cli tx * Update claim example to use ukava large * Use underlying ukava to determine proportional reward amount * Rename liquid methods to reflect derivative value * Add tests for derivative values * Return error to panic in BeginBlocker Co-authored-by: karzak --- app/app.go | 4 +- x/incentive/abci.go | 6 +- x/incentive/client/cli/query.go | 10 ++ x/incentive/client/cli/tx.go | 33 +++++ x/incentive/keeper/rewards_earn.go | 123 ++++++++++++++++-- x/incentive/keeper/rewards_earn_accum_test.go | 81 ++++++------ .../keeper/rewards_earn_staking_test.go | 103 +++++++++++++++ x/incentive/keeper/unit_test.go | 55 +++++++- x/incentive/types/accumulator.go | 31 ++++- x/incentive/types/expected_keepers.go | 9 +- x/liquid/keeper/claim.go | 51 ++++++++ x/liquid/keeper/claim_test.go | 88 +++++++++++++ x/liquid/keeper/derivative.go | 22 ++++ x/liquid/keeper/derivative_test.go | 55 ++++++++ x/liquid/keeper/keeper.go | 24 ++-- x/liquid/keeper/keeper_test.go | 2 +- x/liquid/types/expected_keepers.go | 8 ++ 17 files changed, 625 insertions(+), 80 deletions(-) create mode 100644 x/incentive/keeper/rewards_earn_staking_test.go create mode 100644 x/liquid/keeper/claim.go create mode 100644 x/liquid/keeper/claim_test.go diff --git a/app/app.go b/app/app.go index f0e3af25..3b07e7da 100644 --- a/app/app.go +++ b/app/app.go @@ -594,6 +594,7 @@ func NewApp( app.accountKeeper, app.bankKeeper, &app.stakingKeeper, + &app.distrKeeper, ) savingsKeeper := savingskeeper.NewKeeper( appCodec, @@ -625,8 +626,7 @@ func NewApp( app.stakingKeeper, &swapKeeper, &savingsKeeper, - // TODO: Liquid keeper - nil, + &app.liquidKeeper, &earnKeeper, ) app.routerKeeper = routerkeeper.NewKeeper( diff --git a/x/incentive/abci.go b/x/incentive/abci.go index 7e8b6407..feac0e6c 100644 --- a/x/incentive/abci.go +++ b/x/incentive/abci.go @@ -1,6 +1,8 @@ package incentive import ( + "fmt" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/kava-labs/kava/x/incentive/keeper" @@ -29,6 +31,8 @@ func BeginBlocker(ctx sdk.Context, k keeper.Keeper) { k.AccumulateSavingsRewards(ctx, rp) } for _, rp := range params.EarnRewardPeriods { - k.AccumulateEarnRewards(ctx, rp) + if err := k.AccumulateEarnRewards(ctx, rp); err != nil { + panic(fmt.Sprintf("failed to accumulate earn rewards: %s", err)) + } } } diff --git a/x/incentive/client/cli/query.go b/x/incentive/client/cli/query.go index 248d27b5..6963edab 100644 --- a/x/incentive/client/cli/query.go +++ b/x/incentive/client/cli/query.go @@ -159,6 +159,11 @@ func queryRewardsCmd() *cobra.Command { if err != nil { return err } + earnClaims, err := executeEarnRewardsQuery(cliCtx, params) + if err != nil { + return err + } + if len(hardClaims) > 0 { if err := cliCtx.PrintObjectLegacy(hardClaims); err != nil { return err @@ -184,6 +189,11 @@ func queryRewardsCmd() *cobra.Command { return err } } + if len(earnClaims) > 0 { + if err := cliCtx.PrintObjectLegacy(earnClaims); err != nil { + return err + } + } } return nil }, diff --git a/x/incentive/client/cli/tx.go b/x/incentive/client/cli/tx.go index 81579df2..73067fcf 100644 --- a/x/incentive/client/cli/tx.go +++ b/x/incentive/client/cli/tx.go @@ -32,6 +32,7 @@ func GetTxCmd() *cobra.Command { getCmdClaimDelegator(), getCmdClaimSwap(), getCmdClaimSavings(), + getCmdClaimEarn(), } for _, cmd := range cmds { @@ -209,3 +210,35 @@ func getCmdClaimSavings() *cobra.Command { } return cmd } + +func getCmdClaimEarn() *cobra.Command { + var denomsToClaim map[string]string + + cmd := &cobra.Command{ + Use: "claim-earn", + Short: "claim sender's earn rewards using given multipliers", + Long: `Claim sender's outstanding earn rewards using given multipliers`, + Example: fmt.Sprintf(` $ %s tx %s claim-earn --%s ukava=large`, version.AppName, types.ModuleName, multiplierFlag), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return err + } + + sender := cliCtx.GetFromAddress() + selections := types.NewSelectionsFromMap(denomsToClaim) + + msg := types.NewMsgClaimEarnReward(sender.String(), selections) + if err := msg.ValidateBasic(); err != nil { + return err + } + return tx.GenerateOrBroadcastTxCLI(cliCtx, cmd.Flags(), &msg) + }, + } + cmd.Flags().StringToStringVarP(&denomsToClaim, multiplierFlag, multiplierFlagShort, nil, "specify the denoms to claim, each with a multiplier lockup") + if err := cmd.MarkFlagRequired(multiplierFlag); err != nil { + panic(err) + } + return cmd +} diff --git a/x/incentive/keeper/rewards_earn.go b/x/incentive/keeper/rewards_earn.go index 09d2c5f6..34dd8c46 100644 --- a/x/incentive/keeper/rewards_earn.go +++ b/x/incentive/keeper/rewards_earn.go @@ -1,23 +1,24 @@ package keeper import ( + "errors" "fmt" "sort" - "strings" "time" sdk "github.com/cosmos/cosmos-sdk/types" earntypes "github.com/kava-labs/kava/x/earn/types" "github.com/kava-labs/kava/x/incentive/types" + + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" ) // AccumulateEarnRewards 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) AccumulateEarnRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) { +func (k Keeper) AccumulateEarnRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) error { if rewardPeriod.CollateralType == "bkava" { - k.accumulateEarnBkavaRewards(ctx, rewardPeriod) - return + return k.accumulateEarnBkavaRewards(ctx, rewardPeriod) } k.accumulateEarnRewards( @@ -27,6 +28,8 @@ func (k Keeper) AccumulateEarnRewards(ctx sdk.Context, rewardPeriod types.MultiR rewardPeriod.End, sdk.NewDecCoinsFromCoins(rewardPeriod.RewardsPerSecond...), ) + + return nil } func GetProportionalRewardsPerSecond( @@ -59,16 +62,13 @@ func GetProportionalRewardsPerSecond( // accumulateEarnBkavaRewards does the same as AccumulateEarnRewards but for // *all* bkava vaults. -func (k Keeper) accumulateEarnBkavaRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) { - // TODO: Get staking rewards and distribute - +func (k Keeper) accumulateEarnBkavaRewards(ctx sdk.Context, rewardPeriod types.MultiRewardPeriod) error { // All bkava vault denoms bkavaVaultsDenoms := make(map[string]bool) // bkava vault denoms from earn records (non-empty vaults) k.earnKeeper.IterateVaultRecords(ctx, func(record earntypes.VaultRecord) (stop bool) { - // TODO: Replace with single bkava denom check method from liquid - if strings.HasPrefix(record.TotalShares.Denom, "bkava-") { + if k.liquidKeeper.IsDerivativeDenom(ctx, record.TotalShares.Denom) { bkavaVaultsDenoms[record.TotalShares.Denom] = true } @@ -78,14 +78,17 @@ func (k Keeper) accumulateEarnBkavaRewards(ctx sdk.Context, rewardPeriod types.M // bkava vault denoms from past incentive indexes, may include vaults // that were fully withdrawn. k.IterateEarnRewardIndexes(ctx, func(vaultDenom string, indexes types.RewardIndexes) (stop bool) { - if strings.HasPrefix(vaultDenom, "bkava-") { + if k.liquidKeeper.IsDerivativeDenom(ctx, vaultDenom) { bkavaVaultsDenoms[vaultDenom] = true } return false }) - totalBkavaSupply := k.liquidKeeper.GetTotalDerivativeSupply(ctx) + totalBkavaValue, err := k.liquidKeeper.GetTotalDerivativeValue(ctx) + if err != nil { + return err + } i := 0 sortedBkavaVaultsDenoms := make([]string, len(bkavaVaultsDenoms)) @@ -99,18 +102,110 @@ func (k Keeper) accumulateEarnBkavaRewards(ctx sdk.Context, rewardPeriod types.M // Accumulate rewards for each bkava vault. for _, bkavaDenom := range sortedBkavaVaultsDenoms { - k.accumulateEarnRewards( + derivativeValue, err := k.liquidKeeper.GetDerivativeValue(ctx, bkavaDenom) + if err != nil { + return err + } + + k.accumulateBkavaEarnRewards( ctx, bkavaDenom, rewardPeriod.Start, rewardPeriod.End, GetProportionalRewardsPerSecond( rewardPeriod, - totalBkavaSupply, - k.liquidKeeper.GetDerivativeSupply(ctx, bkavaDenom), + totalBkavaValue.Amount, + derivativeValue.Amount, ), ) } + + return nil +} + +func (k Keeper) accumulateBkavaEarnRewards( + ctx sdk.Context, + collateralType string, + periodStart time.Time, + periodEnd time.Time, + periodRewardsPerSecond sdk.DecCoins, +) { + // Collect staking rewards for this validator, does not have any start/end + // period time restrictions. + stakingRewards := k.collectDerivativeStakingRewards(ctx, collateralType) + + // Collect incentive rewards + // **Total rewards** for vault per second, NOT per share + perSecondRewards := k.collectPerSecondRewards( + ctx, + collateralType, + periodStart, + periodEnd, + periodRewardsPerSecond, + ) + + // **Total rewards** for vault per second, NOT per share + rewards := stakingRewards.Add(perSecondRewards...) + + // Distribute rewards by incrementing indexes + indexes, found := k.GetEarnRewardIndexes(ctx, collateralType) + if !found { + indexes = types.RewardIndexes{} + } + + totalSourceShares := k.getEarnTotalSourceShares(ctx, collateralType) + var increment types.RewardIndexes + if totalSourceShares.GT(sdk.ZeroDec()) { + // Divide total rewards by total shares to get the reward **per share** + // Leave as nil if no source shares + increment = types.NewRewardIndexesFromCoins(rewards).Quo(totalSourceShares) + } + updatedIndexes := indexes.Add(increment) + + if len(updatedIndexes) > 0 { + // the store panics when setting empty or nil indexes + k.SetEarnRewardIndexes(ctx, collateralType, updatedIndexes) + } +} + +func (k Keeper) collectDerivativeStakingRewards(ctx sdk.Context, collateralType string) sdk.DecCoins { + rewards, err := k.liquidKeeper.CollectStakingRewardsByDenom(ctx, collateralType, types.IncentiveMacc) + if err != nil { + if !errors.Is(err, distrtypes.ErrNoValidatorDistInfo) && + !errors.Is(err, distrtypes.ErrEmptyDelegationDistInfo) { + panic(fmt.Sprintf("failed to collect staking rewards for %s: %s", collateralType, err)) + } + + // otherwise there's no validator or delegation yet + rewards = nil + } + return sdk.NewDecCoinsFromCoins(rewards...) +} + +func (k Keeper) collectPerSecondRewards( + ctx sdk.Context, + collateralType string, + periodStart time.Time, + periodEnd time.Time, + periodRewardsPerSecond sdk.DecCoins, +) sdk.DecCoins { + previousAccrualTime, found := k.GetEarnRewardAccrualTime(ctx, collateralType) + if !found { + previousAccrualTime = ctx.BlockTime() + } + + rewards, accumulatedTo := types.CalculatePerSecondRewards( + periodStart, + periodEnd, + periodRewardsPerSecond, + previousAccrualTime, + ctx.BlockTime(), + ) + + k.SetEarnRewardAccrualTime(ctx, collateralType, accumulatedTo) + + // Don't need to move funds as they're assumed to be in the IncentiveMacc module account already. + return rewards } func (k Keeper) accumulateEarnRewards( diff --git a/x/incentive/keeper/rewards_earn_accum_test.go b/x/incentive/keeper/rewards_earn_accum_test.go index 42692fff..29ea56d1 100644 --- a/x/incentive/keeper/rewards_earn_accum_test.go +++ b/x/incentive/keeper/rewards_earn_accum_test.go @@ -94,13 +94,16 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas vaultDenom1 := "bkava-meow" vaultDenom2 := "bkava-woof" + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + earnKeeper := newFakeEarnKeeper(). addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, d("800000"))). addVault(vaultDenom2, earntypes.NewVaultShare(vaultDenom2, d("200000"))) liquidKeeper := newFakeLiquidKeeper(). - addDerivative(vaultDenom1, i(800000)). - addDerivative(vaultDenom2, i(200000)) + addDerivative(suite.ctx, vaultDenom1, i(800000)). + addDerivative(suite.ctx, vaultDenom2, i(200000)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, nil, nil, liquidKeeper, earnKeeper) @@ -134,7 +137,6 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas } suite.storeGlobalEarnIndexes(globalIndexes) - previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom1, previousAccrualTime) suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom2, previousAccrualTime) @@ -164,7 +166,8 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas }, { CollateralType: "ukava", - RewardFactor: d("3.64"), + RewardFactor: d("3.64"). // base incentive + Add(d("360")), // staking rewards, 10% of total bkava per second }, } @@ -179,16 +182,20 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas vaultDenom1Supply := i(800000) vaultDenom2Supply := i(200000) - liquidKeeper := newFakeLiquidKeeper(). - addDerivative(vaultDenom1, vaultDenom1Supply). - addDerivative(vaultDenom2, vaultDenom2Supply) + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + liquidKeeper := newFakeLiquidKeeper(). + addDerivative(suite.ctx, vaultDenom1, vaultDenom1Supply). + addDerivative(suite.ctx, vaultDenom2, vaultDenom2Supply) + + vault1Shares := d("700000") vault2Shares := d("100000") // More bkava minted than deposited into earn // Rewards are higher per-share as a result earnKeeper := newFakeEarnKeeper(). - addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, d("700000"))). + addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, vault1Shares)). addVault(vaultDenom2, earntypes.NewVaultShare(vaultDenom2, vault2Shares)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, nil, nil, liquidKeeper, earnKeeper) @@ -223,7 +230,7 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas } suite.storeGlobalEarnIndexes(globalIndexes) - previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom1, previousAccrualTime) suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom2, previousAccrualTime) @@ -252,7 +259,12 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas }, { CollateralType: "ukava", - RewardFactor: d("4.154285714285714286"), + RewardFactor: d("4.154285714285714286"). // base incentive + Add(vaultDenom1Supply.ToDec(). // staking rewards + QuoInt64(10). + MulInt64(3600). + Quo(vault1Shares), + ), }, }) @@ -278,27 +290,15 @@ func (suite *AccumulateEarnRewardsTests) TestStateUpdatedWhenBlockTimeHasIncreas }, { CollateralType: "ukava", - RewardFactor: d("7.24"), + RewardFactor: d("7.24"). + Add(vaultDenom2Supply.ToDec(). + QuoInt64(10). + MulInt64(3600). + Quo(vault2Shares), + ), }, } suite.storedIndexesEqual(vaultDenom2, vault2expectedIndexes) - - // Verify math described above - totalVault2DistributedUkava := i(int64(time.Hour.Seconds())). - ToDec(). - Mul(rewardPeriod.RewardsPerSecond.AmountOf("ukava").ToDec()). - // 20% of total rewards - // vault 2 supply / (vault 1 supply + vault 2 supply) - Mul( - vaultDenom2Supply.ToDec(). - Quo(vaultDenom1Supply.Add(vaultDenom2Supply).ToDec()), - ) - - totalVault2ClaimableRewards := vault2expectedIndexes[1]. - RewardFactor.Sub(d("0.04")). // Rewards per share for 1 hr, excluding the starting value - Mul(vault2Shares) // * Shares in vault to get total rewards for entire vault - - suite.Equal(totalVault2DistributedUkava, totalVault2ClaimableRewards) } func (suite *AccumulateEarnRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIncreased() { @@ -350,13 +350,16 @@ func (suite *AccumulateEarnRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIn vaultDenom1 := "bkava-meow" vaultDenom2 := "bkava-woof" + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + earnKeeper := newFakeEarnKeeper(). addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, d("1000000"))). addVault(vaultDenom2, earntypes.NewVaultShare(vaultDenom2, d("1000000"))) liquidKeeper := newFakeLiquidKeeper(). - addDerivative(vaultDenom1, i(1000000)). - addDerivative(vaultDenom2, i(1000000)) + addDerivative(suite.ctx, vaultDenom1, i(1000000)). + addDerivative(suite.ctx, vaultDenom2, i(1000000)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, nil, nil, liquidKeeper, earnKeeper) @@ -389,12 +392,10 @@ func (suite *AccumulateEarnRewardsTests) TestStateUnchangedWhenBlockTimeHasNotIn }, } suite.storeGlobalEarnIndexes(previousIndexes) - previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom1, previousAccrualTime) suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom2, previousAccrualTime) - suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) - period := types.NewMultiRewardPeriod( true, "bkava", @@ -585,13 +586,16 @@ func (suite *AccumulateEarnRewardsTests) TestStateAddedWhenStateDoesNotExist_bka vaultDenom1 := "bkava-meow" vaultDenom2 := "bkava-woof" + firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) + earnKeeper := newFakeEarnKeeper(). addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, d("1000000"))). addVault(vaultDenom2, earntypes.NewVaultShare(vaultDenom2, d("1000000"))) liquidKeeper := newFakeLiquidKeeper(). - addDerivative(vaultDenom1, i(1000000)). - addDerivative(vaultDenom2, i(1000000)) + addDerivative(suite.ctx, vaultDenom1, i(1000000)). + addDerivative(suite.ctx, vaultDenom2, i(1000000)) suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, nil, nil, liquidKeeper, earnKeeper) @@ -603,9 +607,6 @@ func (suite *AccumulateEarnRewardsTests) TestStateAddedWhenStateDoesNotExist_bka cs(c("earn", 2000), c("ukava", 1000)), ) - firstAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) - suite.ctx = suite.ctx.WithBlockTime(firstAccrualTime) - suite.keeper.AccumulateEarnRewards(suite.ctx, period) // After the first accumulation only the current block time should be stored. @@ -632,7 +633,9 @@ func (suite *AccumulateEarnRewardsTests) TestStateAddedWhenStateDoesNotExist_bka }, { CollateralType: "ukava", - RewardFactor: d("0.005"), + // 10% of total bkava for rewards per second for 10 seconds + // 1ukava per share per second + regular 0.005ukava incentive rewards + RewardFactor: d("1.005"), }, } diff --git a/x/incentive/keeper/rewards_earn_staking_test.go b/x/incentive/keeper/rewards_earn_staking_test.go new file mode 100644 index 00000000..c4fdc62b --- /dev/null +++ b/x/incentive/keeper/rewards_earn_staking_test.go @@ -0,0 +1,103 @@ +package keeper_test + +import ( + "time" + + earntypes "github.com/kava-labs/kava/x/earn/types" + "github.com/kava-labs/kava/x/incentive/types" +) + +func (suite *AccumulateEarnRewardsTests) TestStakingRewardsDistributed() { + vaultDenom1 := "bkava-meow" + vaultDenom2 := "bkava-woof" + + previousAccrualTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) + suite.ctx = suite.ctx.WithBlockTime(previousAccrualTime) + + vaultDenom1Supply := i(800000) + vaultDenom2Supply := i(200000) + + liquidKeeper := newFakeLiquidKeeper(). + addDerivative(suite.ctx, vaultDenom1, vaultDenom1Supply). + addDerivative(suite.ctx, vaultDenom2, vaultDenom2Supply) + + vault1Shares := d("700000") + vault2Shares := d("100000") + + // More bkava minted than deposited into earn + // Rewards are higher per-share as a result + earnKeeper := newFakeEarnKeeper(). + addVault(vaultDenom1, earntypes.NewVaultShare(vaultDenom1, vault1Shares)). + addVault(vaultDenom2, earntypes.NewVaultShare(vaultDenom2, vault2Shares)) + + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, nil, nil, liquidKeeper, earnKeeper) + + initialVault1RewardFactor := d("0.04") + initialVault2RewardFactor := d("0.04") + + globalIndexes := types.MultiRewardIndexes{ + { + CollateralType: vaultDenom1, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "ukava", + RewardFactor: initialVault1RewardFactor, + }, + }, + }, + { + CollateralType: vaultDenom2, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "ukava", + RewardFactor: initialVault2RewardFactor, + }, + }, + }, + } + + suite.storeGlobalEarnIndexes(globalIndexes) + + suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom1, previousAccrualTime) + suite.keeper.SetEarnRewardAccrualTime(suite.ctx, vaultDenom2, previousAccrualTime) + + newAccrualTime := previousAccrualTime.Add(1 * time.Hour) + suite.ctx = suite.ctx.WithBlockTime(newAccrualTime) + + rewardPeriod := types.NewMultiRewardPeriod( + true, + "bkava", // reward period is set for "bkava" to apply to all vaults + time.Unix(0, 0), // ensure the test is within start and end times + distantFuture, + cs(), // no incentives, so only the staking rewards are distributed + ) + suite.keeper.AccumulateEarnRewards(suite.ctx, rewardPeriod) + + // check time and factors + + suite.storedTimeEquals(vaultDenom1, newAccrualTime) + suite.storedTimeEquals(vaultDenom2, newAccrualTime) + + // Only contains staking rewards + suite.storedIndexesEqual(vaultDenom1, types.RewardIndexes{ + { + CollateralType: "ukava", + RewardFactor: initialVault1RewardFactor. + Add(vaultDenom1Supply.ToDec(). + QuoInt64(10). + MulInt64(3600). + Quo(vault1Shares)), + }, + }) + + suite.storedIndexesEqual(vaultDenom2, types.RewardIndexes{ + { + CollateralType: "ukava", + RewardFactor: initialVault2RewardFactor. + Add(vaultDenom2Supply.ToDec(). + QuoInt64(10). + MulInt64(3600). + Quo(vault2Shares)), + }, + }) +} diff --git a/x/incentive/keeper/unit_test.go b/x/incentive/keeper/unit_test.go index 48ed96eb..dda4a6b8 100644 --- a/x/incentive/keeper/unit_test.go +++ b/x/incentive/keeper/unit_test.go @@ -432,19 +432,26 @@ func (k *fakeEarnKeeper) IterateVaultRecords( // fakeLiquidKeeper is a stub liquid keeper. // It can be used to return values to the incentive keeper without having to initialize a full liquid keeper. type fakeLiquidKeeper struct { - derivatives map[string]sdk.Int + derivatives map[string]sdk.Int + lastRewardClaim map[string]time.Time } var _ types.LiquidKeeper = newFakeLiquidKeeper() func newFakeLiquidKeeper() *fakeLiquidKeeper { return &fakeLiquidKeeper{ - derivatives: map[string]sdk.Int{}, + derivatives: map[string]sdk.Int{}, + lastRewardClaim: map[string]time.Time{}, } } -func (k *fakeLiquidKeeper) addDerivative(denom string, supply sdk.Int) *fakeLiquidKeeper { +func (k *fakeLiquidKeeper) addDerivative( + ctx sdk.Context, + denom string, + supply sdk.Int, +) *fakeLiquidKeeper { k.derivatives[denom] = supply + k.lastRewardClaim[denom] = ctx.BlockTime() return k } @@ -460,22 +467,56 @@ func (k *fakeLiquidKeeper) GetAllDerivativeDenoms(ctx sdk.Context) (denoms []str return denoms } -func (k *fakeLiquidKeeper) GetTotalDerivativeSupply(ctx sdk.Context) sdk.Int { +func (k *fakeLiquidKeeper) GetTotalDerivativeValue(ctx sdk.Context) (sdk.Coin, error) { totalSupply := sdk.ZeroInt() for _, supply := range k.derivatives { totalSupply = totalSupply.Add(supply) } - return totalSupply + return sdk.NewCoin("ukava", totalSupply), nil } -func (k *fakeLiquidKeeper) GetDerivativeSupply(ctx sdk.Context, denom string) sdk.Int { +func (k *fakeLiquidKeeper) GetDerivativeValue(ctx sdk.Context, denom string) (sdk.Coin, error) { supply, found := k.derivatives[denom] if !found { + return sdk.NewCoin("ukava", sdk.ZeroInt()), nil + } + + return sdk.NewCoin("ukava", supply), nil +} + +func (k *fakeLiquidKeeper) CollectStakingRewardsByDenom( + ctx sdk.Context, + derivativeDenom string, + destinationModAccount string, +) (sdk.Coins, error) { + amt := k.getRewardAmount(ctx, derivativeDenom) + + return sdk.NewCoins(sdk.NewCoin("ukava", amt)), nil +} + +func (k *fakeLiquidKeeper) getRewardAmount( + ctx sdk.Context, + derivativeDenom string, +) sdk.Int { + amt, found := k.derivatives[derivativeDenom] + if !found { + // No error return sdk.ZeroInt() } - return supply + lastRewardClaim, found := k.lastRewardClaim[derivativeDenom] + if !found { + panic("last reward claim not found") + } + + duration := int64(ctx.BlockTime().Sub(lastRewardClaim).Seconds()) + if duration <= 0 { + return sdk.ZeroInt() + } + + // Reward amount just set to 10% of the derivative supply per second + return amt.QuoRaw(10).MulRaw(duration) } // Assorted Testing Data diff --git a/x/incentive/types/accumulator.go b/x/incentive/types/accumulator.go index fabf2329..e9937cc8 100644 --- a/x/incentive/types/accumulator.go +++ b/x/incentive/types/accumulator.go @@ -88,7 +88,7 @@ func (*Accumulator) calculateNewRewards(rewardsPerSecond sdk.DecCoins, totalSour // So return an empty increment instead of one full of zeros. return nil } - increment := newRewardIndexesFromCoins(rewardsPerSecond) + increment := NewRewardIndexesFromCoins(rewardsPerSecond) increment = increment.Mul(sdk.NewDec(durationSeconds)).Quo(totalSourceShares) return increment } @@ -109,11 +109,36 @@ func maxTime(t1, t2 time.Time) time.Time { return t1 } -// newRewardIndexesFromCoins is a helper function to initialize a RewardIndexes slice with the values from a Coins slice. -func newRewardIndexesFromCoins(coins sdk.DecCoins) RewardIndexes { +// NewRewardIndexesFromCoins is a helper function to initialize a RewardIndexes slice with the values from a Coins slice. +func NewRewardIndexesFromCoins(coins sdk.DecCoins) RewardIndexes { var indexes RewardIndexes for _, coin := range coins { indexes = append(indexes, NewRewardIndex(coin.Denom, coin.Amount)) } return indexes } + +func CalculatePerSecondRewards( + periodStart time.Time, + periodEnd time.Time, + periodRewardsPerSecond sdk.DecCoins, + previousTime, currentTime time.Time, +) (sdk.DecCoins, time.Time) { + duration := (&Accumulator{}).getTimeElapsedWithinLimits( + previousTime, + currentTime, + periodStart, + periodEnd, + ) + + upTo := minTime(periodEnd, currentTime) + + 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, upTo // TODO + } + + return periodRewardsPerSecond.MulDec(sdk.NewDec(durationSeconds)), upTo +} diff --git a/x/incentive/types/expected_keepers.go b/x/incentive/types/expected_keepers.go index 6cfb8d9c..0e2cb629 100644 --- a/x/incentive/types/expected_keepers.go +++ b/x/incentive/types/expected_keepers.go @@ -74,8 +74,13 @@ type EarnKeeper interface { // LiquidKeeper defines the required methods needed by this modules keeper type LiquidKeeper interface { IsDerivativeDenom(ctx sdk.Context, denom string) bool - GetTotalDerivativeSupply(ctx sdk.Context) sdk.Int - GetDerivativeSupply(ctx sdk.Context, denom string) sdk.Int + GetTotalDerivativeValue(ctx sdk.Context) (sdk.Coin, error) + GetDerivativeValue(ctx sdk.Context, denom string) (sdk.Coin, error) + CollectStakingRewardsByDenom( + ctx sdk.Context, + derivativeDenom string, + destinationModAccount string, + ) (sdk.Coins, error) } // AccountKeeper expected interface for the account keeper (noalias) diff --git a/x/liquid/keeper/claim.go b/x/liquid/keeper/claim.go new file mode 100644 index 00000000..7c115da9 --- /dev/null +++ b/x/liquid/keeper/claim.go @@ -0,0 +1,51 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/liquid/types" +) + +func (k Keeper) CollectStakingRewards( + ctx sdk.Context, + validator sdk.ValAddress, + destinationModAccount string, +) (sdk.Coins, error) { + macc := k.accountKeeper.GetModuleAccount(ctx, types.ModuleAccountName) + + // Ensure withdraw address is as expected + withdrawAddr := k.distributionKeeper.GetDelegatorWithdrawAddr(ctx, macc.GetAddress()) + if !withdrawAddr.Equals(macc.GetAddress()) { + panic(fmt.Sprintf( + "unexpected withdraw address for liquid staking module account, expected %s, got %s", + macc.GetAddress(), withdrawAddr, + )) + } + + rewards, err := k.distributionKeeper.WithdrawDelegationRewards(ctx, macc.GetAddress(), validator) + if err != nil { + return nil, err + } + + err = k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.ModuleAccountName, destinationModAccount, rewards) + if err != nil { + return nil, err + } + + return rewards, nil +} + +func (k Keeper) CollectStakingRewardsByDenom( + ctx sdk.Context, + derivativeDenom string, + destinationModAccount string, +) (sdk.Coins, error) { + valAddr, err := types.ParseLiquidStakingTokenDenom(derivativeDenom) + if err != nil { + return nil, err + } + + return k.CollectStakingRewards(ctx, valAddr, destinationModAccount) +} diff --git a/x/liquid/keeper/claim_test.go b/x/liquid/keeper/claim_test.go new file mode 100644 index 00000000..f4391c19 --- /dev/null +++ b/x/liquid/keeper/claim_test.go @@ -0,0 +1,88 @@ +package keeper_test + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/staking" + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/liquid/types" + + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" +) + +func (suite *KeeperTestSuite) TestCollectStakingRewards() { + _, addrs := app.GeneratePrivKeyAddressPairs(5) + valAccAddr1, delegator := addrs[0], addrs[1] + valAddr1 := sdk.ValAddress(valAccAddr1) + + initialBalance := i(1e9) + delegateAmount := i(100e6) + + suite.NoError(suite.App.FundModuleAccount( + suite.Ctx, + distrtypes.ModuleName, + sdk.NewCoins( + sdk.NewCoin("ukava", initialBalance), + ), + )) + + suite.CreateAccountWithAddress(valAccAddr1, suite.NewBondCoins(initialBalance)) + suite.CreateAccountWithAddress(delegator, suite.NewBondCoins(initialBalance)) + + suite.CreateNewUnbondedValidator(valAddr1, initialBalance) + suite.CreateDelegation(valAddr1, delegator, delegateAmount) + staking.EndBlocker(suite.Ctx, suite.StakingKeeper) + + // Transfers delegation to module account + _, err := suite.Keeper.MintDerivative(suite.Ctx, delegator, valAddr1, suite.NewBondCoin(delegateAmount)) + suite.Require().NoError(err) + + validator, found := suite.StakingKeeper.GetValidator(suite.Ctx, valAddr1) + suite.Require().True(found) + + suite.Ctx = suite.Ctx.WithBlockHeight(2) + + distrKeeper := suite.App.GetDistrKeeper() + stakingKeeper := suite.App.GetStakingKeeper() + accKeeper := suite.App.GetAccountKeeper() + liquidMacc := accKeeper.GetModuleAccount(suite.Ctx, types.ModuleAccountName) + + // Add rewards + rewardCoins := sdk.NewDecCoins(sdk.NewDecCoin("ukava", sdk.NewInt(500e6))) + distrKeeper.AllocateTokensToValidator(suite.Ctx, validator, rewardCoins) + + delegation, found := stakingKeeper.GetDelegation(suite.Ctx, liquidMacc.GetAddress(), valAddr1) + suite.Require().True(found) + + // Get amount of rewards + endingPeriod := distrKeeper.IncrementValidatorPeriod(suite.Ctx, validator) + delegationRewards := distrKeeper.CalculateDelegationRewards(suite.Ctx, validator, delegation, endingPeriod) + truncatedRewards, _ := delegationRewards.TruncateDecimal() + + suite.Run("collect staking rewards", func() { + // Collect rewards + derivativeDenom := suite.Keeper.GetLiquidStakingTokenDenom(valAddr1) + rewards, err := suite.Keeper.CollectStakingRewardsByDenom(suite.Ctx, derivativeDenom, types.ModuleName) + suite.Require().NoError(err) + suite.Require().Equal(truncatedRewards, rewards) + + suite.True(rewards.AmountOf("ukava").IsPositive()) + + // Check balances + suite.AccountBalanceEqual(liquidMacc.GetAddress(), rewards) + }) + + suite.Run("collect staking rewards with non-validator", func() { + // acc2 not a validator + derivativeDenom := suite.Keeper.GetLiquidStakingTokenDenom(sdk.ValAddress(addrs[2])) + _, err := suite.Keeper.CollectStakingRewardsByDenom(suite.Ctx, derivativeDenom, types.ModuleName) + suite.Require().Error(err) + suite.Require().Equal("no validator distribution info", err.Error()) + }) + + suite.Run("collect staking rewards with invalid denom", func() { + derivativeDenom := "bkava" + _, err := suite.Keeper.CollectStakingRewardsByDenom(suite.Ctx, derivativeDenom, types.ModuleName) + suite.Require().Error(err) + suite.Require().Equal("cannot parse denom bkava", err.Error()) + }) +} diff --git a/x/liquid/keeper/derivative.go b/x/liquid/keeper/derivative.go index de3c3162..ccb20074 100644 --- a/x/liquid/keeper/derivative.go +++ b/x/liquid/keeper/derivative.go @@ -136,6 +136,28 @@ func (k Keeper) GetStakedTokensForDerivatives(ctx sdk.Context, coins sdk.Coins) return totalCoin, nil } +// GetTotalDerivativeValue returns the total sum value of all derivative coins +// for all validators denominated by the bond token (ukava). +func (k Keeper) GetTotalDerivativeValue(ctx sdk.Context) (sdk.Coin, error) { + bkavaCoins := sdk.NewCoins() + + k.bankKeeper.IterateTotalSupply(ctx, func(c sdk.Coin) bool { + if k.IsDerivativeDenom(ctx, c.Denom) { + bkavaCoins = bkavaCoins.Add(c) + } + + return false + }) + + return k.GetStakedTokensForDerivatives(ctx, bkavaCoins) +} + +// GetDerivativeValue returns the total underlying value of the provided +// derivative denominated by the bond token (ukava). +func (k Keeper) GetDerivativeValue(ctx sdk.Context, denom string) (sdk.Coin, error) { + return k.GetStakedTokensForDerivatives(ctx, sdk.NewCoins(k.bankKeeper.GetSupply(ctx, denom))) +} + func (k Keeper) mintCoins(ctx sdk.Context, receiver sdk.AccAddress, amount sdk.Coins) error { if err := k.bankKeeper.MintCoins(ctx, types.ModuleAccountName, amount); err != nil { return err diff --git a/x/liquid/keeper/derivative_test.go b/x/liquid/keeper/derivative_test.go index 4bb4b241..e4fb748a 100644 --- a/x/liquid/keeper/derivative_test.go +++ b/x/liquid/keeper/derivative_test.go @@ -470,6 +470,61 @@ func (suite *KeeperTestSuite) TestGetStakedTokensForDerivatives() { } } +func (suite *KeeperTestSuite) TestGetDerivativeValue() { + _, addrs := app.GeneratePrivKeyAddressPairs(5) + valAccAddr1, delegator, valAccAddr2 := addrs[0], addrs[1], addrs[2] + valAddr1 := sdk.ValAddress(valAccAddr1) + + valAddr2 := sdk.ValAddress(valAccAddr2) + + initialBalance := i(1e9) + vestedBalance := i(500e6) + delegateAmount := i(100e6) + + suite.CreateAccountWithAddress(valAccAddr1, suite.NewBondCoins(initialBalance)) + suite.CreateVestingAccountWithAddress(delegator, suite.NewBondCoins(initialBalance), suite.NewBondCoins(vestedBalance)) + + suite.CreateNewUnbondedValidator(valAddr1, initialBalance) + suite.CreateDelegation(valAddr1, delegator, delegateAmount) + + suite.CreateAccountWithAddress(valAccAddr2, suite.NewBondCoins(initialBalance)) + + suite.CreateNewUnbondedValidator(valAddr2, initialBalance) + suite.CreateDelegation(valAddr2, delegator, delegateAmount) + staking.EndBlocker(suite.Ctx, suite.StakingKeeper) + + _, err := suite.Keeper.MintDerivative(suite.Ctx, delegator, valAddr1, suite.NewBondCoin(delegateAmount)) + suite.Require().NoError(err) + + _, err = suite.Keeper.MintDerivative(suite.Ctx, delegator, valAddr2, suite.NewBondCoin(delegateAmount)) + suite.Require().NoError(err) + + suite.SlashValidator(valAddr2, d("0.05")) + + suite.Run("total value", func() { + totalValue, err := suite.Keeper.GetTotalDerivativeValue(suite.Ctx) + suite.Require().NoError(err) + suite.Require().Equal( + // delegateAmount + (delegateAmount * 95%) + delegateAmount.Add(delegateAmount.MulRaw(95).QuoRaw(100)), + totalValue.Amount, + ) + }) + + suite.Run("1:1 derivative value", func() { + derivativeValue, err := suite.Keeper.GetDerivativeValue(suite.Ctx, suite.Keeper.GetLiquidStakingTokenDenom(valAddr1)) + suite.Require().NoError(err) + suite.Require().Equal(suite.NewBondCoin(delegateAmount), derivativeValue) + }) + + suite.Run("slashed derivative value", func() { + derivativeValue, err := suite.Keeper.GetDerivativeValue(suite.Ctx, suite.Keeper.GetLiquidStakingTokenDenom(valAddr2)) + suite.Require().NoError(err) + // delegateAmount * 95% + suite.Require().Equal(delegateAmount.MulRaw(95).QuoRaw(100), derivativeValue.Amount) + }) +} + func (suite *KeeperTestSuite) TestDerivativeFromTokens() { _, addrs := app.GeneratePrivKeyAddressPairs(1) valAccAddr := addrs[0] diff --git a/x/liquid/keeper/keeper.go b/x/liquid/keeper/keeper.go index 0756f38c..7ad528f8 100644 --- a/x/liquid/keeper/keeper.go +++ b/x/liquid/keeper/keeper.go @@ -14,9 +14,10 @@ import ( type Keeper struct { cdc codec.Codec - accountKeeper types.AccountKeeper - bankKeeper types.BankKeeper - stakingKeeper types.StakingKeeper + accountKeeper types.AccountKeeper + bankKeeper types.BankKeeper + stakingKeeper types.StakingKeeper + distributionKeeper types.DistributionKeeper derivativeDenom string } @@ -24,26 +25,27 @@ type Keeper struct { // NewKeeper returns a new keeper for the liquid module. func NewKeeper( cdc codec.Codec, - ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper, + ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper, dk types.DistributionKeeper, derivativeDenom string, ) Keeper { return Keeper{ - cdc: cdc, - accountKeeper: ak, - bankKeeper: bk, - stakingKeeper: sk, - derivativeDenom: derivativeDenom, + cdc: cdc, + accountKeeper: ak, + bankKeeper: bk, + stakingKeeper: sk, + distributionKeeper: dk, + derivativeDenom: derivativeDenom, } } // NewDefaultKeeper returns a new keeper for the liquid module with default values. func NewDefaultKeeper( cdc codec.Codec, - ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper, + ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper, dk types.DistributionKeeper, ) Keeper { - return NewKeeper(cdc, ak, bk, sk, types.DefaultDerivativeDenom) + return NewKeeper(cdc, ak, bk, sk, dk, types.DefaultDerivativeDenom) } // Logger returns a module-specific logger. diff --git a/x/liquid/keeper/keeper_test.go b/x/liquid/keeper/keeper_test.go index 37a16a37..8a7597ee 100644 --- a/x/liquid/keeper/keeper_test.go +++ b/x/liquid/keeper/keeper_test.go @@ -94,7 +94,7 @@ func (suite *KeeperTestSuite) AddCoinsToModule(module string, amount sdk.Coins) // AccountBalanceEqual checks if an account has the specified coins. func (suite *KeeperTestSuite) AccountBalanceEqual(addr sdk.AccAddress, coins sdk.Coins) { balance := suite.BankKeeper.GetAllBalances(suite.Ctx, addr) - suite.Equalf(coins, balance, "expected account balance to equal coins %s, but got %s", coins, balance) + suite.Truef(coins.IsEqual(balance), "expected account balance to equal coins %s, but got %s", coins, balance) } func (suite *KeeperTestSuite) deliverMsgCreateValidator(ctx sdk.Context, address sdk.ValAddress, selfDelegation sdk.Coin) error { diff --git a/x/liquid/types/expected_keepers.go b/x/liquid/types/expected_keepers.go index 5466983a..4f0b1eb8 100644 --- a/x/liquid/types/expected_keepers.go +++ b/x/liquid/types/expected_keepers.go @@ -17,6 +17,9 @@ type BankKeeper interface { MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error UndelegateCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error + + IterateTotalSupply(ctx sdk.Context, cb func(sdk.Coin) bool) + GetSupply(ctx sdk.Context, denom string) sdk.Coin } // AccountKeeper defines the expected keeper interface for interacting with account @@ -45,3 +48,8 @@ type StakingKeeper interface { ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress, shares sdk.Dec, ) (amount sdk.Int, err error) } + +type DistributionKeeper interface { + GetDelegatorWithdrawAddr(ctx sdk.Context, delAddr sdk.AccAddress) sdk.AccAddress + WithdrawDelegationRewards(ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress) (sdk.Coins, error) +}