diff --git a/x/incentive/keeper/adapters/swap/adapter.go b/x/incentive/keeper/adapters/swap/adapter.go new file mode 100644 index 00000000..d3c5a1aa --- /dev/null +++ b/x/incentive/keeper/adapters/swap/adapter.go @@ -0,0 +1,46 @@ +package swap + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/incentive/types" +) + +var _ types.SourceAdapter = SourceAdapter{} + +type SourceAdapter struct { + keeper types.SwapKeeper +} + +func NewSourceAdapter(keeper types.SwapKeeper) SourceAdapter { + return SourceAdapter{ + keeper: keeper, + } +} + +func (f SourceAdapter) TotalSharesBySource(ctx sdk.Context, sourceID string) sdk.Dec { + shares, found := f.keeper.GetPoolShares(ctx, sourceID) + if !found { + shares = sdk.ZeroInt() + } + + return shares.ToDec() +} + +func (f SourceAdapter) OwnerSharesBySource( + ctx sdk.Context, + owner sdk.AccAddress, + sourceIDs []string, +) map[string]sdk.Dec { + shares := make(map[string]sdk.Dec) + for _, id := range sourceIDs { + s, found := f.keeper.GetDepositorSharesAmount(ctx, owner, id) + if !found { + s = sdk.ZeroInt() + } + + shares[id] = s.ToDec() + } + + return shares +} diff --git a/x/incentive/keeper/adapters/swap/adapter_test.go b/x/incentive/keeper/adapters/swap/adapter_test.go new file mode 100644 index 00000000..1905069b --- /dev/null +++ b/x/incentive/keeper/adapters/swap/adapter_test.go @@ -0,0 +1,283 @@ +package swap_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + tmprototypes "github.com/tendermint/tendermint/proto/tendermint/types" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/incentive/keeper/adapters/swap" + swaptypes "github.com/kava-labs/kava/x/swap/types" + "github.com/stretchr/testify/suite" +) + +type SwapAdapterTestSuite struct { + suite.Suite + + app app.TestApp + ctx sdk.Context + + genesisTime time.Time + addrs []sdk.AccAddress +} + +func TestSwapAdapterTestSuite(t *testing.T) { + suite.Run(t, new(SwapAdapterTestSuite)) +} + +func (suite *SwapAdapterTestSuite) SetupTest() { + config := sdk.GetConfig() + app.SetBech32AddressPrefixes(config) + + _, suite.addrs = app.GeneratePrivKeyAddressPairs(5) + + suite.genesisTime = time.Date(2020, 12, 15, 14, 0, 0, 0, time.UTC) + suite.app = app.NewTestApp() + + suite.ctx = suite.app.NewContext(true, tmprototypes.Header{Time: suite.genesisTime}) +} + +func (suite *SwapAdapterTestSuite) TestSwapAdapter_OwnerSharesBySource_Empty() { + adapter := swap.NewSourceAdapter(suite.app.GetSwapKeeper()) + + tests := []struct { + name string + giveOwner sdk.AccAddress + giveSourceIDs []string + wantShares map[string]sdk.Dec + }{ + { + "empty requests", + suite.addrs[0], + []string{}, + map[string]sdk.Dec{}, + }, + { + "empty pools are zero", + suite.addrs[0], + []string{ + "pool1", + "pool2", + "pool3", + }, + map[string]sdk.Dec{ + "pool1": sdk.ZeroDec(), + "pool2": sdk.ZeroDec(), + "pool3": sdk.ZeroDec(), + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + shares := adapter.OwnerSharesBySource(suite.ctx, tt.giveOwner, tt.giveSourceIDs) + + suite.Equal(tt.wantShares, shares) + }) + } +} + +func (suite *SwapAdapterTestSuite) TestSwapAdapter_OwnerSharesBySource() { + poolDenomA := "ukava" + poolDenomB := "usdx" + + swapKeeper := suite.app.GetSwapKeeper() + swapKeeper.SetParams(suite.ctx, swaptypes.NewParams( + swaptypes.NewAllowedPools( + swaptypes.NewAllowedPool(poolDenomA, poolDenomB), + ), + sdk.ZeroDec(), + )) + + suite.app.FundAccount( + suite.ctx, + suite.addrs[0], + sdk.NewCoins( + sdk.NewCoin(poolDenomA, sdk.NewInt(1000000000000)), + sdk.NewCoin(poolDenomB, sdk.NewInt(1000000000000)), + ), + ) + suite.app.FundAccount( + suite.ctx, + suite.addrs[1], + sdk.NewCoins( + sdk.NewCoin(poolDenomA, sdk.NewInt(1000000000000)), + sdk.NewCoin(poolDenomB, sdk.NewInt(1000000000000)), + ), + ) + + err := swapKeeper.Deposit( + suite.ctx, + suite.addrs[0], + sdk.NewCoin(poolDenomA, sdk.NewInt(100)), + sdk.NewCoin(poolDenomB, sdk.NewInt(100)), + sdk.NewDecWithPrec(1, 1), + ) + suite.NoError(err) + + err = swapKeeper.Deposit( + suite.ctx, + suite.addrs[1], + sdk.NewCoin(poolDenomA, sdk.NewInt(250)), + sdk.NewCoin(poolDenomB, sdk.NewInt(250)), + sdk.NewDecWithPrec(1, 0), + ) + suite.NoError(err) + + adapter := swap.NewSourceAdapter(suite.app.GetSwapKeeper()) + + tests := []struct { + name string + giveOwner sdk.AccAddress + giveSourceIDs []string + wantShares map[string]sdk.Dec + }{ + { + "depositor has shares", + suite.addrs[0], + []string{ + swaptypes.PoolID(poolDenomA, poolDenomB), + }, + map[string]sdk.Dec{ + swaptypes.PoolID(poolDenomA, poolDenomB): sdk.NewDecWithPrec(100, 0), + }, + }, + { + "depositor has shares - including empty deposits", + suite.addrs[1], + []string{ + swaptypes.PoolID(poolDenomA, poolDenomB), + "pool2", + }, + map[string]sdk.Dec{ + swaptypes.PoolID(poolDenomA, poolDenomB): sdk.NewDecWithPrec(250, 0), + "pool2": sdk.ZeroDec(), + }, + }, + { + "non-depositor has zero shares", + suite.addrs[2], + []string{ + swaptypes.PoolID(poolDenomA, poolDenomB), + }, + map[string]sdk.Dec{ + swaptypes.PoolID(poolDenomA, poolDenomB): sdk.ZeroDec(), + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + shares := adapter.OwnerSharesBySource(suite.ctx, tt.giveOwner, tt.giveSourceIDs) + + suite.Equal(tt.wantShares, shares) + }) + } +} + +func (suite *SwapAdapterTestSuite) TestSwapAdapter_TotalSharesBySource_Empty() { + adapter := swap.NewSourceAdapter(suite.app.GetSwapKeeper()) + + tests := []struct { + name string + giveSourceID string + wantShares sdk.Dec + }{ + { + "empty/invalid pools are zero", + "pool1", + sdk.ZeroDec(), + }, + { + "invalid request returns zero", + "", + sdk.ZeroDec(), + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + shares := adapter.TotalSharesBySource(suite.ctx, tt.giveSourceID) + + suite.Equal(tt.wantShares, shares) + }) + } +} + +func (suite *SwapAdapterTestSuite) TestSwapAdapter_TotalSharesBySource() { + poolDenomA := "ukava" + poolDenomB := "usdx" + + swapKeeper := suite.app.GetSwapKeeper() + swapKeeper.SetParams(suite.ctx, swaptypes.NewParams( + swaptypes.NewAllowedPools( + swaptypes.NewAllowedPool(poolDenomA, poolDenomB), + ), + sdk.ZeroDec(), + )) + + suite.app.FundAccount( + suite.ctx, + suite.addrs[0], + sdk.NewCoins( + sdk.NewCoin(poolDenomA, sdk.NewInt(1000000000000)), + sdk.NewCoin(poolDenomB, sdk.NewInt(1000000000000)), + ), + ) + suite.app.FundAccount( + suite.ctx, + suite.addrs[1], + sdk.NewCoins( + sdk.NewCoin(poolDenomA, sdk.NewInt(1000000000000)), + sdk.NewCoin(poolDenomB, sdk.NewInt(1000000000000)), + ), + ) + + err := swapKeeper.Deposit( + suite.ctx, + suite.addrs[0], + sdk.NewCoin(poolDenomA, sdk.NewInt(100)), + sdk.NewCoin(poolDenomB, sdk.NewInt(100)), + sdk.NewDecWithPrec(1, 1), + ) + suite.NoError(err) + + err = swapKeeper.Deposit( + suite.ctx, + suite.addrs[1], + sdk.NewCoin(poolDenomA, sdk.NewInt(250)), + sdk.NewCoin(poolDenomB, sdk.NewInt(250)), + sdk.NewDecWithPrec(1, 0), + ) + suite.NoError(err) + + adapter := swap.NewSourceAdapter(suite.app.GetSwapKeeper()) + + tests := []struct { + name string + giveSourceID string + wantShares sdk.Dec + }{ + { + "total shares", + swaptypes.PoolID(poolDenomA, poolDenomB), + sdk.NewDecWithPrec(350, 0), + }, + { + "empty or invalid pool empty", + "pool2", + sdk.ZeroDec(), + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + shares := adapter.TotalSharesBySource(suite.ctx, tt.giveSourceID) + + suite.Equal(tt.wantShares, shares) + }) + } +} diff --git a/x/incentive/keeper/keeper.go b/x/incentive/keeper/keeper.go index ad38a69b..a9a08ba9 100644 --- a/x/incentive/keeper/keeper.go +++ b/x/incentive/keeper/keeper.go @@ -1,12 +1,14 @@ package keeper import ( + "fmt" "time" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/keeper/adapters/swap" "github.com/kava-labs/kava/x/incentive/types" ) @@ -25,6 +27,8 @@ type Keeper struct { liquidKeeper types.LiquidKeeper earnKeeper types.EarnKeeper + adapters map[types.ClaimType]types.SourceAdapter + // Keepers used for APY queries mintKeeper types.MintKeeper distrKeeper types.DistrKeeper @@ -43,18 +47,23 @@ func NewKeeper( } return Keeper{ - accountKeeper: ak, - cdc: cdc, - key: key, - paramSubspace: paramstore, - bankKeeper: bk, - cdpKeeper: cdpk, - hardKeeper: hk, - stakingKeeper: stk, - swapKeeper: swpk, - savingsKeeper: svk, - liquidKeeper: lqk, - earnKeeper: ek, + accountKeeper: ak, + cdc: cdc, + key: key, + paramSubspace: paramstore, + bankKeeper: bk, + cdpKeeper: cdpk, + hardKeeper: hk, + stakingKeeper: stk, + swapKeeper: swpk, + savingsKeeper: svk, + liquidKeeper: lqk, + earnKeeper: ek, + + adapters: map[types.ClaimType]types.SourceAdapter{ + types.CLAIM_TYPE_SWAP: swap.NewSourceAdapter(swpk), + }, + mintKeeper: mk, distrKeeper: dk, pricefeedKeeper: pfk, @@ -885,6 +894,15 @@ func (k Keeper) IterateEarnRewardAccrualTimes(ctx sdk.Context, cb func(string, t // ----------------------------------------------------------------------------- // New deduplicated methods +func (k Keeper) GetSourceAdapter(claimType types.ClaimType) types.SourceAdapter { + fetcher, found := k.adapters[claimType] + if !found { + panic(fmt.Sprintf("no source share fetcher for claim type %s", claimType)) + } + + return fetcher +} + // GetClaim returns the claim in the store corresponding the the owner and // claimType, and a boolean for if the claim was found func (k Keeper) GetClaim( diff --git a/x/incentive/keeper/rewards.go b/x/incentive/keeper/rewards.go index cfd7cda9..342df77a 100644 --- a/x/incentive/keeper/rewards.go +++ b/x/incentive/keeper/rewards.go @@ -36,7 +36,7 @@ func (k Keeper) SynchronizeClaim( claimType types.ClaimType, sourceID string, owner sdk.AccAddress, - shares sdk.Int, + shares sdk.Dec, ) { claim, found := k.GetClaim(ctx, claimType, owner) if !found { @@ -53,7 +53,7 @@ func (k *Keeper) synchronizeClaim( claim types.Claim, sourceID string, owner sdk.AccAddress, - shares sdk.Int, + shares sdk.Dec, ) types.Claim { globalRewardIndexes, found := k.GetRewardIndexesOfClaimType(ctx, claim.Type, sourceID) if !found { @@ -74,7 +74,7 @@ func (k *Keeper) synchronizeClaim( userRewardIndexes = types.RewardIndexes{} } - newRewards, err := k.CalculateRewards(userRewardIndexes, globalRewardIndexes, shares.ToDec()) + newRewards, err := k.CalculateRewards(userRewardIndexes, globalRewardIndexes, shares) if err != nil { // Global reward factors should never decrease, as it would lead to a negative update to claim.Rewards. // This panics if a global reward factor decreases or disappears between the old and new indexes. @@ -86,3 +86,33 @@ func (k *Keeper) synchronizeClaim( return claim } + +// GetSynchronizedClaim fetches a claim from the store and syncs rewards for all +// rewarded sourceIDs. +func (k Keeper) GetSynchronizedClaim( + ctx sdk.Context, + claimType types.ClaimType, + owner sdk.AccAddress, +) (types.Claim, bool) { + claim, found := k.GetClaim(ctx, claimType, owner) + if !found { + return types.Claim{}, false + } + + // Fetch all source IDs from indexes + var sourceIDs []string + k.IterateRewardIndexesByClaimType(ctx, claimType, func(rewardIndexes types.TypedRewardIndexes) bool { + sourceIDs = append(sourceIDs, rewardIndexes.CollateralType) + return false + }) + + adapter := k.GetSourceAdapter(claimType) + accShares := adapter.OwnerSharesBySource(ctx, owner, sourceIDs) + + // Synchronize claim for each source ID + for _, sourceID := range sourceIDs { + claim = k.synchronizeClaim(ctx, claim, sourceID, owner, accShares[sourceID]) + } + + return claim, true +} diff --git a/x/incentive/keeper/rewards_sync_test.go b/x/incentive/keeper/rewards_sync_test.go index 2811e61b..07ba1165 100644 --- a/x/incentive/keeper/rewards_sync_test.go +++ b/x/incentive/keeper/rewards_sync_test.go @@ -68,7 +68,7 @@ func (suite *SynchronizeClaimTests) TestClaimUpdatedWhenGlobalIndexesHaveIncreas userShares := i(1e9) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) // indexes updated from global @@ -110,7 +110,7 @@ func (suite *SynchronizeClaimTests) TestClaimUnchangedWhenGlobalIndexesUnchanged userShares := i(1e9) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) // claim should have the same rewards and indexes as before @@ -169,7 +169,7 @@ func (suite *SynchronizeClaimTests) TestClaimUpdatedWhenNewRewardAdded() { userShares := i(1e9) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, newlyRewardcollateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, newlyRewardcollateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) // the new indexes should be added to the claim, but the old ones should be unchanged @@ -203,7 +203,7 @@ func (suite *SynchronizeClaimTests) TestClaimUnchangedWhenNoReward() { userShares := i(1e9) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) suite.Equal(claim, syncedClaim) @@ -256,7 +256,7 @@ func (suite *SynchronizeClaimTests) TestClaimUpdatedWhenNewRewardDenomAdded() { userShares := i(1e9) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) // indexes should have the new reward denom added @@ -310,7 +310,7 @@ func (suite *SynchronizeClaimTests) TestClaimUpdatedWhenGlobalIndexesIncreasedAn userShares := i(0) - suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares) + suite.keeper.SynchronizeClaim(suite.ctx, claimType, collateralType, claim.Owner, userShares.ToDec()) syncedClaim, _ := suite.keeper.GetClaim(suite.ctx, claimType, claim.Owner) // indexes updated from global @@ -318,3 +318,155 @@ func (suite *SynchronizeClaimTests) TestClaimUpdatedWhenGlobalIndexesIncreasedAn // reward is unchanged suite.Equal(claim.Reward, syncedClaim.Reward) } + +func (suite *SynchronizeClaimTests) TestGetSyncedClaim_ClaimUnchangedWhenNoGlobalIndexes() { + collateralType_1 := "btcb:usdx" + owner := arbitraryAddress() + claimType := types.CLAIM_TYPE_SWAP + + swapKeeper := newFakeSwapKeeper(). + addDeposit(collateralType_1, owner, i(1e9)) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper, nil, nil, nil) + + claim := types.Claim{ + Type: claimType, + Owner: owner, + Reward: nil, + RewardIndexes: types.MultiRewardIndexes{ + { + CollateralType: collateralType_1, + RewardIndexes: nil, // this state only happens because Init stores empty indexes + }, + }, + } + suite.keeper.SetClaim(suite.ctx, claim) + + // no global indexes for any pool + + syncedClaim, f := suite.keeper.GetSynchronizedClaim(suite.ctx, claimType, claim.Owner) + suite.True(f) + + // indexes are unchanged + suite.Equal(claim.RewardIndexes, syncedClaim.RewardIndexes) + // reward is unchanged + suite.Equal(claim.Reward, syncedClaim.Reward) +} + +func (suite *SynchronizeClaimTests) TestGetSyncedClaim_ClaimUpdatedWhenMissingIndexAndHasNoSourceShares() { + collateralType_1 := "btcb:usdx" + collateralType_2 := "ukava:usdx" + owner := arbitraryAddress() + claimType := types.CLAIM_TYPE_SWAP + + // owner has no shares in any pool + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, newFakeSwapKeeper(), nil, nil, nil) + + claim := types.Claim{ + Type: claimType, + Owner: owner, + Reward: arbitraryCoins(), + RewardIndexes: types.MultiRewardIndexes{ + { + CollateralType: collateralType_1, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom1", + RewardFactor: d("1000.001"), + }, + }, + }, + }, + } + suite.keeper.SetClaim(suite.ctx, claim) + + globalIndexes := types.MultiRewardIndexes{ + { + CollateralType: collateralType_1, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom1", + RewardFactor: d("2000.002"), + }, + }, + }, + { + CollateralType: collateralType_2, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom2", + RewardFactor: d("2000.002"), + }, + }, + }, + } + suite.storeGlobalIndexes(claimType, globalIndexes) + + syncedClaim, f := suite.keeper.GetSynchronizedClaim(suite.ctx, claimType, claim.Owner) + suite.True(f) + + // indexes updated from global + suite.Equal(globalIndexes, syncedClaim.RewardIndexes) + // reward is unchanged + suite.Equal(claim.Reward, syncedClaim.Reward) +} + +func (suite *SynchronizeClaimTests) TestGetSyncedClaim_ClaimUpdatedWhenMissingIndexButHasSourceShares() { + collateralType_1 := "btcb:usdx" + collateralType_2 := "ukava:usdx" + owner := arbitraryAddress() + claimType := types.CLAIM_TYPE_SWAP + + swapKeeper := newFakeSwapKeeper(). + addDeposit(collateralType_1, owner, i(1e9)). + addDeposit(collateralType_2, owner, i(1e9)) + suite.keeper = suite.NewKeeper(&fakeParamSubspace{}, nil, nil, nil, nil, nil, swapKeeper, nil, nil, nil) + + claim := types.Claim{ + Type: claimType, + Owner: owner, + Reward: arbitraryCoins(), + RewardIndexes: types.MultiRewardIndexes{ + { + CollateralType: collateralType_1, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom1", + RewardFactor: d("1000.001"), + }, + }, + }, + }, + } + suite.keeper.SetClaim(suite.ctx, claim) + + globalIndexes := types.MultiRewardIndexes{ + { + CollateralType: collateralType_1, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom1", + RewardFactor: d("2000.002"), + }, + }, + }, + { + CollateralType: collateralType_2, + RewardIndexes: types.RewardIndexes{ + { + CollateralType: "rewarddenom2", + RewardFactor: d("2000.002"), + }, + }, + }, + } + suite.storeGlobalIndexes(claimType, globalIndexes) + + syncedClaim, f := suite.keeper.GetSynchronizedClaim(suite.ctx, claimType, claim.Owner) + suite.True(f) + + // indexes updated from global + suite.Equal(globalIndexes, syncedClaim.RewardIndexes) + // reward is incremented + expectedReward := cs(c("rewarddenom1", 1_000_001_000_000), c("rewarddenom2", 2_000_002_000_000)) + suite.Equal(claim.Reward.Add(expectedReward...), syncedClaim.Reward) +}