package keeper_test import ( "errors" "testing" sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" 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" ) // SynchronizeHardBorrowRewardTests runs unit tests for the keeper.SynchronizeHardBorrowReward method type SynchronizeHardBorrowRewardTests struct { unitTester } func TestSynchronizeHardBorrowReward(t *testing.T) { suite.Run(t, new(SynchronizeHardBorrowRewardTests)) } func (suite *SynchronizeHardBorrowRewardTests) TestClaimIndexesAreUpdatedWhenGlobalIndexesHaveIncreased() { // This is the normal case claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), }, BorrowRewardIndexes: nonEmptyMultiRewardIndexes, } suite.storeHardClaim(claim) globalIndexes := increaseAllRewardFactors(nonEmptyMultiRewardIndexes) suite.storeGlobalBorrowIndexes(globalIndexes) borrow := NewBorrowBuilder(claim.Owner). WithArbitrarySourceShares(extractCollateralTypes(claim.BorrowRewardIndexes)...). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal(globalIndexes, syncedClaim.BorrowRewardIndexes) } func (suite *SynchronizeHardBorrowRewardTests) TestClaimIndexesAreUnchangedWhenGlobalIndexesUnchanged() { // It should be safe to call SynchronizeHardBorrowReward multiple times unchangingIndexes := nonEmptyMultiRewardIndexes claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), }, BorrowRewardIndexes: unchangingIndexes, } suite.storeHardClaim(claim) suite.storeGlobalBorrowIndexes(unchangingIndexes) borrow := NewBorrowBuilder(claim.Owner). WithArbitrarySourceShares(extractCollateralTypes(unchangingIndexes)...). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal(unchangingIndexes, syncedClaim.BorrowRewardIndexes) } func (suite *SynchronizeHardBorrowRewardTests) TestClaimIndexesAreUpdatedWhenNewRewardAdded() { // When a new reward is added (via gov) for a hard borrow denom the user has already borrowed, and the claim is synced; // Then the new reward's index should be added to the claim. claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), }, BorrowRewardIndexes: nonEmptyMultiRewardIndexes, } suite.storeHardClaim(claim) globalIndexes := appendUniqueMultiRewardIndex(nonEmptyMultiRewardIndexes) suite.storeGlobalBorrowIndexes(globalIndexes) borrow := NewBorrowBuilder(claim.Owner). WithArbitrarySourceShares(extractCollateralTypes(globalIndexes)...). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal(globalIndexes, syncedClaim.BorrowRewardIndexes) } func (suite *SynchronizeHardBorrowRewardTests) TestClaimIndexesAreUpdatedWhenNewRewardDenomAdded() { // When a new reward coin is added (via gov) to an already rewarded borrow denom (that the user has already borrowed), and the claim is synced; // Then the new reward coin's index should be added to the claim. claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), }, BorrowRewardIndexes: nonEmptyMultiRewardIndexes, } suite.storeHardClaim(claim) globalIndexes := appendUniqueRewardIndexToFirstItem(nonEmptyMultiRewardIndexes) suite.storeGlobalBorrowIndexes(globalIndexes) borrow := NewBorrowBuilder(claim.Owner). WithArbitrarySourceShares(extractCollateralTypes(globalIndexes)...). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal(globalIndexes, syncedClaim.BorrowRewardIndexes) } func (suite *SynchronizeHardBorrowRewardTests) TestRewardIsIncrementedWhenGlobalIndexesHaveIncreased() { // This is the normal case // Given some time has passed (meaning the global indexes have increased) // When the claim is synced // The user earns rewards for the time passed originalReward := arbitraryCoins() claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), Reward: originalReward, }, BorrowRewardIndexes: types.MultiRewardIndexes{ { CollateralType: "borrowdenom", RewardIndexes: types.RewardIndexes{ { CollateralType: "rewarddenom", RewardFactor: d("1000.001"), }, }, }, }, } suite.storeHardClaim(claim) suite.storeGlobalBorrowIndexes(types.MultiRewardIndexes{ { CollateralType: "borrowdenom", RewardIndexes: types.RewardIndexes{ { CollateralType: "rewarddenom", RewardFactor: d("2000.002"), }, }, }, }) borrow := NewBorrowBuilder(claim.Owner). WithSourceShares("borrowdenom", 1e9). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) // new reward is (new index - old index) * borrow amount syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal( cs(c("rewarddenom", 1_000_001_000_000)).Add(originalReward...), syncedClaim.Reward, ) } func (suite *SynchronizeHardBorrowRewardTests) TestRewardIsIncrementedWhenNewRewardAdded() { // When a new reward is added (via gov) for a hard borrow denom the user has already borrowed, and the claim is synced // Then the user earns rewards for the time since the reward was added originalReward := arbitraryCoins() claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), Reward: originalReward, }, BorrowRewardIndexes: types.MultiRewardIndexes{ { CollateralType: "rewarded", RewardIndexes: types.RewardIndexes{ { CollateralType: "reward", RewardFactor: d("1000.001"), }, }, }, }, } suite.storeHardClaim(claim) globalIndexes := types.MultiRewardIndexes{ { CollateralType: "rewarded", RewardIndexes: types.RewardIndexes{ { CollateralType: "reward", RewardFactor: d("2000.002"), }, }, }, { CollateralType: "newlyrewarded", RewardIndexes: types.RewardIndexes{ { CollateralType: "otherreward", // Indexes start at 0 when the reward is added by gov, // so this represents the syncing happening some time later. RewardFactor: d("1000.001"), }, }, }, } suite.storeGlobalBorrowIndexes(globalIndexes) borrow := NewBorrowBuilder(claim.Owner). WithSourceShares("rewarded", 1e9). WithSourceShares("newlyrewarded", 1e9). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) // new reward is (new index - old index) * borrow amount for each borrowed denom // The old index for `newlyrewarded` isn't in the claim, so it's added starting at 0 for calculating the reward. syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal( cs(c("otherreward", 1_000_001_000_000), c("reward", 1_000_001_000_000)).Add(originalReward...), syncedClaim.Reward, ) } func (suite *SynchronizeHardBorrowRewardTests) TestRewardIsIncrementedWhenNewRewardDenomAdded() { // When a new reward coin is added (via gov) to an already rewarded borrow denom (that the user has already borrowed), and the claim is synced; // Then the user earns rewards for the time since the reward was added originalReward := arbitraryCoins() claim := types.HardLiquidityProviderClaim{ BaseMultiClaim: types.BaseMultiClaim{ Owner: arbitraryAddress(), Reward: originalReward, }, BorrowRewardIndexes: types.MultiRewardIndexes{ { CollateralType: "borrowed", RewardIndexes: types.RewardIndexes{ { CollateralType: "reward", RewardFactor: d("1000.001"), }, }, }, }, } suite.storeHardClaim(claim) globalIndexes := types.MultiRewardIndexes{ { CollateralType: "borrowed", RewardIndexes: types.RewardIndexes{ { CollateralType: "reward", RewardFactor: d("2000.002"), }, { CollateralType: "otherreward", // Indexes start at 0 when the reward is added by gov, // so this represents the syncing happening some time later. RewardFactor: d("1000.001"), }, }, }, } suite.storeGlobalBorrowIndexes(globalIndexes) borrow := NewBorrowBuilder(claim.Owner). WithSourceShares("borrowed", 1e9). Build() suite.keeper.SynchronizeHardBorrowReward(suite.ctx, borrow) // new reward is (new index - old index) * borrow amount for each borrowed denom // The old index for `otherreward` isn't in the claim, so it's added starting at 0 for calculating the reward. syncedClaim, _ := suite.keeper.GetHardLiquidityProviderClaim(suite.ctx, claim.Owner) suite.Equal( cs(c("reward", 1_000_001_000_000), c("otherreward", 1_000_001_000_000)).Add(originalReward...), syncedClaim.Reward, ) } // BorrowBuilder is a tool for creating a hard borrows. // The builder inherits from hard.Borrow, so fields can be accessed directly if a helper method doesn't exist. type BorrowBuilder struct { hardtypes.Borrow } // NewBorrowBuilder creates a BorrowBuilder containing an empty borrow. func NewBorrowBuilder(borrower sdk.AccAddress) BorrowBuilder { return BorrowBuilder{ Borrow: hardtypes.Borrow{ Borrower: borrower, }, } } // Build assembles and returns the final borrow. func (builder BorrowBuilder) Build() hardtypes.Borrow { return builder.Borrow } // WithSourceShares adds a borrow amount and factor such that the source shares for this borrow is equal to specified. // With a factor of 1, the borrow amount is the source shares. This picks an arbitrary factor to ensure factors are accounted for in production code. func (builder BorrowBuilder) WithSourceShares(denom string, shares int64) BorrowBuilder { if !builder.Amount.AmountOf(denom).Equal(sdk.ZeroInt()) { panic("adding to amount with existing denom not implemented") } if _, f := builder.Index.GetInterestFactor(denom); f { panic("adding to indexes with existing denom not implemented") } // pick arbitrary factor factor := sdk.MustNewDecFromStr("2") // Calculate borrow amount that would equal the requested source shares given the above factor. amt := sdkmath.NewInt(shares).Mul(factor.RoundInt()) builder.Amount = builder.Amount.Add(sdk.NewCoin(denom, amt)) builder.Index = builder.Index.SetInterestFactor(denom, factor) return builder } // WithArbitrarySourceShares adds arbitrary borrow amounts and indexes for each specified denom. func (builder BorrowBuilder) WithArbitrarySourceShares(denoms ...string) BorrowBuilder { const arbitraryShares = 1e9 for _, denom := range denoms { builder = builder.WithSourceShares(denom, arbitraryShares) } return builder } func TestCalculateRewards(t *testing.T) { type expected struct { err error coins sdk.Coins } type args struct { oldIndexes, newIndexes types.RewardIndexes sourceAmount sdk.Dec } testcases := []struct { name string args args expected expected }{ { name: "when old and new indexes have same denoms, rewards are calculated correctly", args: args{ oldIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.000000001"), }, { CollateralType: "ukava", RewardFactor: d("0.1"), }, }, newIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("1000.0"), }, { CollateralType: "ukava", RewardFactor: d("0.100000001"), }, }, sourceAmount: d("1000000000"), }, expected: expected{ // for each denom: (new - old) * sourceAmount coins: cs(c("hard", 999999999999), c("ukava", 1)), }, }, { name: "when new indexes have an extra denom, rewards are calculated as if it was 0 in old indexes", args: args{ oldIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.000000001"), }, }, newIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("1000.0"), }, { CollateralType: "ukava", RewardFactor: d("0.100000001"), }, }, sourceAmount: d("1000000000"), }, expected: expected{ // for each denom: (new - old) * sourceAmount coins: cs(c("hard", 999999999999), c("ukava", 100000001)), }, }, { name: "when new indexes are smaller than old, an error is returned", args: args{ oldIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.2"), }, }, newIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.1"), }, }, sourceAmount: d("1000000000"), }, expected: expected{ err: types.ErrDecreasingRewardFactor, }, }, { name: "when old indexes have an extra denom, an error is returned", args: args{ oldIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.1"), }, { CollateralType: "ukava", RewardFactor: d("0.1"), }, }, newIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.2"), }, }, sourceAmount: d("1000000000"), }, expected: expected{ err: types.ErrDecreasingRewardFactor, }, }, { name: "when old and new indexes are 0, rewards are 0", args: args{ oldIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.0"), }, }, newIndexes: types.RewardIndexes{ { CollateralType: "hard", RewardFactor: d("0.0"), }, }, sourceAmount: d("1000000000"), }, expected: expected{ coins: nil, }, }, { name: "when old and new indexes are empty, rewards are 0", args: args{ oldIndexes: types.RewardIndexes{}, newIndexes: nil, sourceAmount: d("1000000000"), }, expected: expected{ coins: nil, }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { coins, err := keeper.Keeper{}.CalculateRewards(tc.args.oldIndexes, tc.args.newIndexes, tc.args.sourceAmount) if tc.expected.err != nil { require.True(t, errors.Is(err, tc.expected.err)) } else { require.Equal(t, tc.expected.coins, coins) } }) } } func TestCalculateSingleReward(t *testing.T) { type expected struct { err error reward sdkmath.Int } type args struct { oldIndex, newIndex sdk.Dec sourceAmount sdk.Dec } testcases := []struct { name string args args expected expected }{ { name: "when new index is > old, rewards are calculated correctly", args: args{ oldIndex: d("0.000000001"), newIndex: d("1000.0"), sourceAmount: d("1000000000"), }, expected: expected{ // (new - old) * sourceAmount reward: i(999999999999), }, }, { name: "when new index is < old, an error is returned", args: args{ oldIndex: d("0.000000001"), newIndex: d("0.0"), sourceAmount: d("1000000000"), }, expected: expected{ err: types.ErrDecreasingRewardFactor, }, }, { name: "when old and new indexes are 0, rewards are 0", args: args{ oldIndex: d("0.0"), newIndex: d("0.0"), sourceAmount: d("1000000000"), }, expected: expected{ reward: sdk.ZeroInt(), }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { reward, err := keeper.Keeper{}.CalculateSingleReward(tc.args.oldIndex, tc.args.newIndex, tc.args.sourceAmount) if tc.expected.err != nil { require.True(t, errors.Is(err, tc.expected.err)) } else { require.Equal(t, tc.expected.reward, reward) } }) } }