diff --git a/app/app.go b/app/app.go index 151deeaa..da424542 100644 --- a/app/app.go +++ b/app/app.go @@ -7,6 +7,7 @@ import ( "github.com/kava-labs/kava/x/auction" "github.com/kava-labs/kava/x/bep3" "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/incentive" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" @@ -69,6 +70,7 @@ var ( pricefeed.AppModuleBasic{}, bep3.AppModuleBasic{}, kavadist.AppModuleBasic{}, + incentive.AppModuleBasic{}, ) // module account permissions @@ -121,6 +123,7 @@ type App struct { pricefeedKeeper pricefeed.Keeper bep3Keeper bep3.Keeper kavadistKeeper kavadist.Keeper + incentiveKeeper incentive.Keeper // the module manager mm *module.Manager @@ -145,7 +148,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, supply.StoreKey, mint.StoreKey, distr.StoreKey, slashing.StoreKey, gov.StoreKey, params.StoreKey, evidence.StoreKey, validatorvesting.StoreKey, auction.StoreKey, cdp.StoreKey, pricefeed.StoreKey, bep3.StoreKey, - kavadist.StoreKey, + kavadist.StoreKey, incentive.StoreKey, ) tkeys := sdk.NewTransientStoreKeys(params.TStoreKey) @@ -173,6 +176,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, pricefeedSubspace := app.paramsKeeper.Subspace(pricefeed.DefaultParamspace) bep3Subspace := app.paramsKeeper.Subspace(bep3.DefaultParamspace) kavadistSubspace := app.paramsKeeper.Subspace(kavadist.DefaultParamspace) + incentiveSubspace := app.paramsKeeper.Subspace(incentive.DefaultParamspace) // add keepers app.accountKeeper = auth.NewAccountKeeper( @@ -295,6 +299,14 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, kavadistSubspace, app.supplyKeeper, ) + app.incentiveKeeper = incentive.NewKeeper( + app.cdc, + keys[incentive.StoreKey], + incentiveSubspace, + app.supplyKeeper, + app.cdpKeeper, + app.accountKeeper, + ) // register the staking hooks // NOTE: stakingKeeper above is passed by reference, so that it will contain these hooks @@ -321,12 +333,13 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, pricefeed.NewAppModule(app.pricefeedKeeper, app.accountKeeper), bep3.NewAppModule(app.bep3Keeper, app.accountKeeper, app.supplyKeeper), kavadist.NewAppModule(app.kavadistKeeper, app.supplyKeeper), + incentive.NewAppModule(app.incentiveKeeper, app.supplyKeeper), ) // During begin block slashing happens after distr.BeginBlocker so that // there is nothing left over in the validator fee pool, so as to keep the // CanWithdrawInvariant invariant. - app.mm.SetOrderBeginBlockers(mint.ModuleName, distr.ModuleName, slashing.ModuleName, validatorvesting.ModuleName, kavadist.ModuleName, cdp.ModuleName, auction.ModuleName, bep3.ModuleName) + app.mm.SetOrderBeginBlockers(mint.ModuleName, distr.ModuleName, slashing.ModuleName, validatorvesting.ModuleName, kavadist.ModuleName, cdp.ModuleName, auction.ModuleName, bep3.ModuleName, incentive.ModuleName) app.mm.SetOrderEndBlockers(crisis.ModuleName, gov.ModuleName, staking.ModuleName, pricefeed.ModuleName) @@ -335,7 +348,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, validatorvesting.ModuleName, distr.ModuleName, staking.ModuleName, bank.ModuleName, slashing.ModuleName, gov.ModuleName, mint.ModuleName, evidence.ModuleName, - pricefeed.ModuleName, cdp.ModuleName, auction.ModuleName, bep3.ModuleName, kavadist.ModuleName, // TODO is this order ok? + pricefeed.ModuleName, cdp.ModuleName, auction.ModuleName, bep3.ModuleName, kavadist.ModuleName, incentive.ModuleName, supply.ModuleName, // calculates the total supply from account - should run after modules that modify accounts in genesis crisis.ModuleName, // runs the invariants at genesis - should run after other modules genutil.ModuleName, // genutils must occur after staking so that pools are properly initialized with tokens from genesis accounts. @@ -363,6 +376,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, auction.NewAppModule(app.auctionKeeper, app.accountKeeper, app.supplyKeeper), bep3.NewAppModule(app.bep3Keeper, app.accountKeeper, app.supplyKeeper), kavadist.NewAppModule(app.kavadistKeeper, app.supplyKeeper), + incentive.NewAppModule(app.incentiveKeeper, app.supplyKeeper), ) app.sm.RegisterStoreDecoders() diff --git a/app/test_common.go b/app/test_common.go index 42bc00a5..bb1ab7e7 100644 --- a/app/test_common.go +++ b/app/test_common.go @@ -30,6 +30,7 @@ import ( "github.com/kava-labs/kava/x/auction" "github.com/kava-labs/kava/x/bep3" "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/incentive" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" @@ -75,6 +76,7 @@ func (tApp TestApp) GetCDPKeeper() cdp.Keeper { return tApp.cdpKeepe func (tApp TestApp) GetPriceFeedKeeper() pricefeed.Keeper { return tApp.pricefeedKeeper } func (tApp TestApp) GetBep3Keeper() bep3.Keeper { return tApp.bep3Keeper } func (tApp TestApp) GetKavadistKeeper() kavadist.Keeper { return tApp.kavadistKeeper } +func (tApp TestApp) GetIncentiveKeeper() incentive.Keeper { return tApp.incentiveKeeper } // This calls InitChain on the app using the default genesis state, overwitten with any passed in genesis states func (tApp TestApp) InitializeFromGenesisStates(genesisStates ...GenesisState) TestApp { diff --git a/go.sum b/go.sum index 27038216..d69e9d6c 100644 --- a/go.sum +++ b/go.sum @@ -416,6 +416,7 @@ github.com/tendermint/iavl v0.13.2/go.mod h1:vE1u0XAGXYjHykd4BLp8p/yivrw2PF1Tuol github.com/tendermint/tendermint v0.33.2/go.mod h1:25DqB7YvV1tN3tHsjWoc2vFtlwICfrub9XO6UBO+4xk= github.com/tendermint/tendermint v0.33.3 h1:6lMqjEoCGejCzAghbvfQgmw87snGSqEhDTo/jw+W8CI= github.com/tendermint/tendermint v0.33.3/go.mod h1:25DqB7YvV1tN3tHsjWoc2vFtlwICfrub9XO6UBO+4xk= +github.com/tendermint/tendermint v0.33.4 h1:NM3G9618yC5PaaxGrcAySc5ylc1PAANeIx42u2Re/jo= github.com/tendermint/tm-db v0.4.1/go.mod h1:JsJ6qzYkCGiGwm5GHl/H5GLI9XLb6qZX7PRe425dHAY= github.com/tendermint/tm-db v0.5.0 h1:qtM5UTr1dlRnHtDY6y7MZO5Di8XAE2j3lc/pCnKJ5hQ= github.com/tendermint/tm-db v0.5.0/go.mod h1:lSq7q5WRR/njf1LnhiZ/lIJHk2S8Y1Zyq5oP/3o9C2U= diff --git a/x/bep3/client/rest/query.go b/x/bep3/client/rest/query.go index 7170cf03..559919f5 100644 --- a/x/bep3/client/rest/query.go +++ b/x/bep3/client/rest/query.go @@ -37,7 +37,6 @@ func queryAtomicSwapHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) return } - swapID, err := types.HexToBytes(vars[restSwapID]) if err != nil { rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) diff --git a/x/incentive/abci.go b/x/incentive/abci.go new file mode 100644 index 00000000..e6c0b293 --- /dev/null +++ b/x/incentive/abci.go @@ -0,0 +1,13 @@ +package incentive + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/keeper" +) + +// BeginBlocker runs at the start of every block +func BeginBlocker(ctx sdk.Context, k keeper.Keeper) { + k.DeleteExpiredClaimsAndClaimPeriods(ctx) + k.ApplyRewardsToCdps(ctx) + k.CreateAndDeleteRewardPeriods(ctx) +} diff --git a/x/incentive/alias.go b/x/incentive/alias.go new file mode 100644 index 00000000..877ddba3 --- /dev/null +++ b/x/incentive/alias.go @@ -0,0 +1,92 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/incentive/keeper +// ALIASGEN: github.com/kava-labs/kava/x/incentive/types +package incentive + +import ( + "github.com/kava-labs/kava/x/incentive/keeper" + "github.com/kava-labs/kava/x/incentive/types" +) + +const ( + EventTypeClaim = types.EventTypeClaim + AttributeValueCategory = types.AttributeValueCategory + AttributeKeySender = types.AttributeKeySender + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + DefaultParamspace = types.DefaultParamspace + QuerierRoute = types.QuerierRoute + QueryGetClaims = types.QueryGetClaims + RestClaimOwner = types.RestClaimOwner + RestClaimDenom = types.RestClaimDenom + QueryGetParams = types.QueryGetParams +) + +var ( + // functions aliases + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + GetTotalVestingPeriodLength = types.GetTotalVestingPeriodLength + RegisterCodec = types.RegisterCodec + NewGenesisState = types.NewGenesisState + DefaultGenesisState = types.DefaultGenesisState + BytesToUint64 = types.BytesToUint64 + GetClaimPeriodPrefix = types.GetClaimPeriodPrefix + GetClaimPrefix = types.GetClaimPrefix + NewMsgClaimReward = types.NewMsgClaimReward + NewParams = types.NewParams + DefaultParams = types.DefaultParams + ParamKeyTable = types.ParamKeyTable + NewReward = types.NewReward + NewPeriod = types.NewPeriod + NewQueryClaimsParams = types.NewQueryClaimsParams + NewRewardPeriod = types.NewRewardPeriod + NewClaimPeriod = types.NewClaimPeriod + NewClaim = types.NewClaim + + // variable aliases + ModuleCdc = types.ModuleCdc + ErrClaimNotFound = types.ErrClaimNotFound + ErrClaimPeriodNotFound = types.ErrClaimPeriodNotFound + ErrInvalidAccountType = types.ErrInvalidAccountType + ErrNoClaimsFound = types.ErrNoClaimsFound + ErrInsufficientModAccountBalance = types.ErrInsufficientModAccountBalance + RewardPeriodKeyPrefix = types.RewardPeriodKeyPrefix + ClaimPeriodKeyPrefix = types.ClaimPeriodKeyPrefix + ClaimKeyPrefix = types.ClaimKeyPrefix + NextClaimPeriodIDPrefix = types.NextClaimPeriodIDPrefix + PreviousBlockTimeKey = types.PreviousBlockTimeKey + KeyActive = types.KeyActive + KeyRewards = types.KeyRewards + DefaultActive = types.DefaultActive + DefaultRewards = types.DefaultRewards + DefaultPreviousBlockTime = types.DefaultPreviousBlockTime + GovDenom = types.GovDenom + PrincipalDenom = types.PrincipalDenom + IncentiveMacc = types.IncentiveMacc +) + +type ( + Keeper = keeper.Keeper + SupplyKeeper = types.SupplyKeeper + CdpKeeper = types.CdpKeeper + AccountKeeper = types.AccountKeeper + GenesisClaimPeriodID = types.GenesisClaimPeriodID + GenesisClaimPeriodIDs = types.GenesisClaimPeriodIDs + GenesisState = types.GenesisState + MsgClaimReward = types.MsgClaimReward + Params = types.Params + Reward = types.Reward + Rewards = types.Rewards + QueryClaimsParams = types.QueryClaimsParams + PostClaimReq = types.PostClaimReq + RewardPeriod = types.RewardPeriod + RewardPeriods = types.RewardPeriods + ClaimPeriod = types.ClaimPeriod + ClaimPeriods = types.ClaimPeriods + Claim = types.Claim + Claims = types.Claims +) diff --git a/x/incentive/client/cli/query.go b/x/incentive/client/cli/query.go new file mode 100644 index 00000000..c9201c53 --- /dev/null +++ b/x/incentive/client/cli/query.go @@ -0,0 +1,98 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/spf13/cobra" +) + +// GetQueryCmd returns the cli query commands for the incentive module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + incentiveQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Querying commands for the incentive module", + } + + incentiveQueryCmd.AddCommand(flags.GetCommands( + queryParamsCmd(queryRoute, cdc), + queryClaimsCmd(queryRoute, cdc), + )...) + + return incentiveQueryCmd + +} + +func queryClaimsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "claims [owner-addr] [denom]", + Short: "get claims by onwer and denom", + Long: strings.TrimSpace( + fmt.Sprintf(`Get all claims owned by the owner address for the particular collateral type. + + Example: + $ %s query %s claims kava15qdefkmwswysgg4qxgqpqr35k3m49pkx2jdfnw bnb`, version.ClientName, types.ModuleName)), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + // Prepare params for querier + ownerAddress, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + bz, err := cdc.MarshalJSON(types.QueryClaimsParams{ + Owner: ownerAddress, + Denom: args[1], + }) + if err != nil { + return err + } + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetClaims) + res, _, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + + var claims types.Claims + if err := cdc.UnmarshalJSON(res, &claims); err != nil { + return fmt.Errorf("failed to unmarshal claims: %w", err) + } + return cliCtx.PrintOutput(claims) + + }, + } +} + +func queryParamsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "params", + Short: "get the incentive module parameters", + Long: "Get the current global incentive module parameters.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetParams) + res, _, err := cliCtx.QueryWithData(route, nil) + if err != nil { + return err + } + + // Decode and print results + var params types.Params + if err := cdc.UnmarshalJSON(res, ¶ms); err != nil { + return fmt.Errorf("failed to unmarshal params: %w", err) + } + return cliCtx.PrintOutput(params) + }, + } +} diff --git a/x/incentive/client/cli/tx.go b/x/incentive/client/cli/tx.go new file mode 100644 index 00000000..559cbd37 --- /dev/null +++ b/x/incentive/client/cli/tx.go @@ -0,0 +1,63 @@ +package cli + +import ( + "bufio" + "fmt" + "strings" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/spf13/cobra" +) + +// GetTxCmd returns the transaction cli commands for the incentive module +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + incentiveTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "transaction commands for the incentive module", + } + + incentiveTxCmd.AddCommand(flags.PostCommands( + getCmdClaim(cdc), + )...) + + return incentiveTxCmd + +} + +func getCmdClaim(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "claim [owner] [denom]", + Short: "claim rewards for owner and denom", + Long: strings.TrimSpace( + fmt.Sprintf(`Claim any outstanding rewards owned by owner for the input denom, + + Example: + $ %s tx %s claim kava15qdefkmwswysgg4qxgqpqr35k3m49pkx2jdfnw bnb + `, version.ClientName, types.ModuleName), + ), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + owner, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + + msg := types.NewMsgClaimReward(owner, args[1]) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/incentive/client/rest/query.go b/x/incentive/client/rest/query.go new file mode 100644 index 00000000..e8e0e5e1 --- /dev/null +++ b/x/incentive/client/rest/query.go @@ -0,0 +1,70 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/gorilla/mux" + "github.com/kava-labs/kava/x/incentive/types" +) + +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/%s/claims", types.ModuleName), queryClaimsHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/parameters", types.ModuleName), queryParamsHandlerFn(cliCtx)).Methods("GET") +} + +func queryClaimsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + vars := mux.Vars(r) + ownerBech32 := vars[types.RestClaimOwner] + denom := vars[types.RestClaimDenom] + + owner, err := sdk.AccAddressFromBech32(ownerBech32) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + queryParams := types.NewQueryClaimsParams(owner, denom) + bz, err := cliCtx.Codec.MarshalJSON(queryParams) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/incentive/%s", types.QueryGetClaims), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + + } +} + +func queryParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + route := fmt.Sprintf("custom/%s/parameters", types.QuerierRoute) + + res, height, err := cliCtx.QueryWithData(route, nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/incentive/client/rest/rest.go b/x/incentive/client/rest/rest.go new file mode 100644 index 00000000..9c41f78f --- /dev/null +++ b/x/incentive/client/rest/rest.go @@ -0,0 +1,13 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// RegisterRoutes registers incentive-related REST handlers to a router +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} diff --git a/x/incentive/client/rest/tx.go b/x/incentive/client/rest/tx.go new file mode 100644 index 00000000..13ccd94a --- /dev/null +++ b/x/incentive/client/rest/tx.go @@ -0,0 +1,36 @@ +package rest + +import ( + "net/http" + + "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/gorilla/mux" + "github.com/kava-labs/kava/x/incentive/types" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc("/incentive/claim", postClaimHandlerFn(cliCtx)).Methods("POST") + +} + +func postClaimHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody types.PostClaimReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + msg := types.NewMsgClaimReward(requestBody.Sender, requestBody.Denom) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/incentive/doc.go b/x/incentive/doc.go new file mode 100644 index 00000000..f2b92511 --- /dev/null +++ b/x/incentive/doc.go @@ -0,0 +1,40 @@ +/* +Package incentive implements a Cosmos SDK module, per ADR 009, that provides governance-controlled, +on-chain incentives for users who open cdps and mint stablecoins (USDX). + +For the background and motivation of this module, see the governance proposal that was voted on by +KAVA token holders: https://ipfs.io/ipfs/QmSYedssC3nyQacDJmNcREtgmTPyaMx2JX7RNkMdAVkdkr/user-growth-fund-proposal.pdf + +The 'Reward' parameter is used to control how much incentives are given. +For example, the following reward: + + Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", 1000000000), + Duration: time.Hour*7*24, + TimeLock: time.Hour*24*365, + ClaimDuration: time.Hour*7*24, + } + +will distribute 1000 KAVA each week (Duration) to users who mint USDX using collateral bnb. +That KAVA can be claimed by the user for one week (ClaimDuration) after the reward period expires, +and all KAVA rewards will be timelocked for 1 year (TimeLock). If a user does not claim them during +the claim duration period, they are forgone. + +Rewards are accumulated by users continuously and proportionally - ie. if a user holds a CDP that has +minted 10% of all USDX backed by bnb for the entire reward period, they will be eligible to claim 10% +of rewards for that period. + +Once a reward period ends, but not before, users can claim the rewards they have accumulated. Users claim rewards +using a MsgClaimReward transaction. The following msg: + + MsgClaimReward { + "kava1..." + "bnb" + } + +will claim all outstanding rewards for minting USDX backed by bnb for the input user. + +*/ +package incentive diff --git a/x/incentive/genesis.go b/x/incentive/genesis.go new file mode 100644 index 00000000..5a38e146 --- /dev/null +++ b/x/incentive/genesis.go @@ -0,0 +1,73 @@ +package incentive + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/keeper" + "github.com/kava-labs/kava/x/incentive/types" +) + +// InitGenesis initializes the store state from a genesis state. +func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeeper, gs types.GenesisState) { + + // check if the module account exists + moduleAcc := supplyKeeper.GetModuleAccount(ctx, types.IncentiveMacc) + if moduleAcc == nil { + panic(fmt.Sprintf("%s module account has not been set", types.IncentiveMacc)) + } + + if err := gs.Validate(); err != nil { + panic(fmt.Sprintf("failed to validate %s genesis state: %s", types.ModuleName, err)) + } + + k.SetParams(ctx, gs.Params) + + for _, r := range gs.Params.Rewards { + k.SetNextClaimPeriodID(ctx, r.Denom, 1) + } + + // only set the previous block time if it's different than default + if !gs.PreviousBlockTime.Equal(types.DefaultPreviousBlockTime) { + k.SetPreviousBlockTime(ctx, gs.PreviousBlockTime) + } + + // set store objects + for _, rp := range gs.RewardPeriods { + k.SetRewardPeriod(ctx, rp) + } + + for _, cp := range gs.ClaimPeriods { + k.SetClaimPeriod(ctx, cp) + } + + for _, c := range gs.Claims { + k.SetClaim(ctx, c) + } + + for _, id := range gs.NextClaimPeriodIDs { + k.SetNextClaimPeriodID(ctx, id.Denom, id.ID) + } + +} + +// ExportGenesis export genesis state for incentive module +func ExportGenesis(ctx sdk.Context, k keeper.Keeper) types.GenesisState { + // get all objects out of the store + params := k.GetParams(ctx) + previousBlockTime, found := k.GetPreviousBlockTime(ctx) + + // since it is not set in genesis, if somehow the chain got started and was exported + // immediately after InitGenesis, there would be no previousBlockTime value. + if !found { + previousBlockTime = types.DefaultPreviousBlockTime + } + + // Get all objects from the store + rewardPeriods := k.GetAllRewardPeriods(ctx) + claimPeriods := k.GetAllClaimPeriods(ctx) + claims := k.GetAllClaims(ctx) + claimPeriodIDs := k.GetAllClaimPeriodIDPairs(ctx) + + return types.NewGenesisState(params, previousBlockTime, rewardPeriods, claimPeriods, claims, claimPeriodIDs) +} diff --git a/x/incentive/handler.go b/x/incentive/handler.go new file mode 100644 index 00000000..1fdf8a58 --- /dev/null +++ b/x/incentive/handler.go @@ -0,0 +1,48 @@ +package incentive + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/kava-labs/kava/x/incentive/keeper" + "github.com/kava-labs/kava/x/incentive/types" +) + +// NewHandler creates an sdk.Handler for incentive module messages +func NewHandler(k keeper.Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + switch msg := msg.(type) { + case types.MsgClaimReward: + return handleMsgClaimReward(ctx, k, msg) + default: + return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized %s message type: %T", ModuleName, msg) + + } + } +} + +func handleMsgClaimReward(ctx sdk.Context, k keeper.Keeper, msg types.MsgClaimReward) (*sdk.Result, error) { + + claims, found := k.GetClaimsByAddressAndDenom(ctx, msg.Sender, msg.Denom) + if !found { + return nil, sdkerrors.Wrapf(types.ErrNoClaimsFound, "address: %s, denom: %s", msg.Sender, msg.Denom) + } + + for _, claim := range claims { + err := k.PayoutClaim(ctx, claim.Owner, claim.Denom, claim.ClaimPeriodID) + if err != nil { + return nil, err + } + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} diff --git a/x/incentive/keeper/keeper.go b/x/incentive/keeper/keeper.go new file mode 100644 index 00000000..3c2aa383 --- /dev/null +++ b/x/incentive/keeper/keeper.go @@ -0,0 +1,243 @@ +package keeper + +import ( + "time" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store/prefix" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" + "github.com/kava-labs/kava/x/incentive/types" +) + +// Keeper keeper for the incentive module +type Keeper struct { + accountKeeper types.AccountKeeper + cdc *codec.Codec + cdpKeeper types.CdpKeeper + key sdk.StoreKey + paramSubspace subspace.Subspace + supplyKeeper types.SupplyKeeper +} + +// NewKeeper creates a new keeper +func NewKeeper( + cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, sk types.SupplyKeeper, + cdpk types.CdpKeeper, ak types.AccountKeeper, +) Keeper { + + return Keeper{ + accountKeeper: ak, + cdc: cdc, + cdpKeeper: cdpk, + key: key, + paramSubspace: paramstore.WithKeyTable(types.ParamKeyTable()), + supplyKeeper: sk, + } +} + +// GetRewardPeriod returns the reward period from the store for the input denom and a boolean for if it was found +func (k Keeper) GetRewardPeriod(ctx sdk.Context, denom string) (types.RewardPeriod, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.RewardPeriodKeyPrefix) + bz := store.Get([]byte(denom)) + if bz == nil { + return types.RewardPeriod{}, false + } + var rp types.RewardPeriod + k.cdc.MustUnmarshalBinaryBare(bz, &rp) + return rp, true +} + +// SetRewardPeriod sets the reward period in the store for the input deno, +func (k Keeper) SetRewardPeriod(ctx sdk.Context, rp types.RewardPeriod) { + store := prefix.NewStore(ctx.KVStore(k.key), types.RewardPeriodKeyPrefix) + bz := k.cdc.MustMarshalBinaryBare(rp) + store.Set([]byte(rp.Denom), bz) +} + +// DeleteRewardPeriod deletes the reward period in the store for the input denom, +func (k Keeper) DeleteRewardPeriod(ctx sdk.Context, denom string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.RewardPeriodKeyPrefix) + store.Delete([]byte(denom)) +} + +// IterateRewardPeriods iterates over all reward period objects in the store and preforms a callback function +func (k Keeper) IterateRewardPeriods(ctx sdk.Context, cb func(rp types.RewardPeriod) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.RewardPeriodKeyPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var rp types.RewardPeriod + k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &rp) + if cb(rp) { + break + } + } +} + +// GetAllRewardPeriods returns all reward periods in the store +func (k Keeper) GetAllRewardPeriods(ctx sdk.Context) types.RewardPeriods { + rps := types.RewardPeriods{} + k.IterateRewardPeriods(ctx, func(rp types.RewardPeriod) (stop bool) { + rps = append(rps, rp) + return false + }) + return rps +} + +// GetNextClaimPeriodID returns the highest claim period id in the store for the input denom +func (k Keeper) GetNextClaimPeriodID(ctx sdk.Context, denom string) uint64 { + store := prefix.NewStore(ctx.KVStore(k.key), types.NextClaimPeriodIDPrefix) + bz := store.Get([]byte(denom)) + return types.BytesToUint64(bz) +} + +// SetNextClaimPeriodID sets the highest claim period id in the store for the input denom +func (k Keeper) SetNextClaimPeriodID(ctx sdk.Context, denom string, id uint64) { + store := prefix.NewStore(ctx.KVStore(k.key), types.NextClaimPeriodIDPrefix) + store.Set([]byte(denom), sdk.Uint64ToBigEndian(id)) +} + +// IterateClaimPeriodIDKeysAndValues iterates over the claim period id (value) and denom (key) of each claim period id in the store and performs a callback function +func (k Keeper) IterateClaimPeriodIDKeysAndValues(ctx sdk.Context, cb func(denom string, id uint64) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.NextClaimPeriodIDPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + id := types.BytesToUint64(iterator.Value()) + denom := string(iterator.Key()) + if cb(denom, id) { + break + } + } +} + +// GetAllClaimPeriodIDPairs returns all denom:nextClaimPeriodID pairs in the store +func (k Keeper) GetAllClaimPeriodIDPairs(ctx sdk.Context) types.GenesisClaimPeriodIDs { + ids := types.GenesisClaimPeriodIDs{} + k.IterateClaimPeriodIDKeysAndValues(ctx, func(denom string, id uint64) (stop bool) { + genID := types.GenesisClaimPeriodID{ + Denom: denom, + ID: id, + } + ids = append(ids, genID) + return false + }) + return ids +} + +// GetClaimPeriod returns claim period in the store for the input ID and denom and a boolean for if it was found +func (k Keeper) GetClaimPeriod(ctx sdk.Context, id uint64, denom string) (types.ClaimPeriod, bool) { + var cp types.ClaimPeriod + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimPeriodKeyPrefix) + bz := store.Get(types.GetClaimPeriodPrefix(denom, id)) + if bz == nil { + return types.ClaimPeriod{}, false + } + k.cdc.MustUnmarshalBinaryBare(bz, &cp) + return cp, true +} + +// SetClaimPeriod sets the claim period in the store for the input ID and denom +func (k Keeper) SetClaimPeriod(ctx sdk.Context, cp types.ClaimPeriod) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimPeriodKeyPrefix) + bz := k.cdc.MustMarshalBinaryBare(cp) + store.Set(types.GetClaimPeriodPrefix(cp.Denom, cp.ID), bz) +} + +// DeleteClaimPeriod deletes the claim period in the store for the input ID and denom +func (k Keeper) DeleteClaimPeriod(ctx sdk.Context, id uint64, denom string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimPeriodKeyPrefix) + store.Delete(types.GetClaimPeriodPrefix(denom, id)) +} + +// IterateClaimPeriods iterates over all claim period objects in the store and preforms a callback function +func (k Keeper) IterateClaimPeriods(ctx sdk.Context, cb func(cp types.ClaimPeriod) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimPeriodKeyPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var cp types.ClaimPeriod + k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &cp) + if cb(cp) { + break + } + } +} + +// GetAllClaimPeriods returns all ClaimPeriod objects in the store +func (k Keeper) GetAllClaimPeriods(ctx sdk.Context) types.ClaimPeriods { + cps := types.ClaimPeriods{} + k.IterateClaimPeriods(ctx, func(cp types.ClaimPeriod) (stop bool) { + cps = append(cps, cp) + return false + }) + return cps +} + +// GetClaim returns the claim in the store corresponding the the input address denom and id and a boolean for if the claim was found +func (k Keeper) GetClaim(ctx sdk.Context, addr sdk.AccAddress, denom string, id uint64) (types.Claim, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimKeyPrefix) + bz := store.Get(types.GetClaimPrefix(addr, denom, id)) + if bz == nil { + return types.Claim{}, false + } + var c types.Claim + k.cdc.MustUnmarshalBinaryBare(bz, &c) + return c, true +} + +// SetClaim sets the claim in the store corresponding to the input address, denom, and id +func (k Keeper) SetClaim(ctx sdk.Context, c types.Claim) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimKeyPrefix) + bz := k.cdc.MustMarshalBinaryBare(c) + store.Set(types.GetClaimPrefix(c.Owner, c.Denom, c.ClaimPeriodID), bz) + +} + +// DeleteClaim deletes the claim in the store corresponding to the input address, denom, and id +func (k Keeper) DeleteClaim(ctx sdk.Context, owner sdk.AccAddress, denom string, id uint64) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimKeyPrefix) + store.Delete(types.GetClaimPrefix(owner, denom, id)) +} + +// IterateClaims iterates over all claim objects in the store and preforms a callback function +func (k Keeper) IterateClaims(ctx sdk.Context, cb func(c types.Claim) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.ClaimKeyPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var c types.Claim + k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &c) + if cb(c) { + break + } + } +} + +// GetAllClaims returns all Claim objects in the store +func (k Keeper) GetAllClaims(ctx sdk.Context) types.Claims { + cs := types.Claims{} + k.IterateClaims(ctx, func(c types.Claim) (stop bool) { + cs = append(cs, c) + return false + }) + return cs +} + +// GetPreviousBlockTime get the blocktime for the previous block +func (k Keeper) GetPreviousBlockTime(ctx sdk.Context) (blockTime time.Time, found bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousBlockTimeKey) + b := store.Get([]byte{}) + if b == nil { + return time.Time{}, false + } + k.cdc.MustUnmarshalBinaryBare(b, &blockTime) + return blockTime, true +} + +// SetPreviousBlockTime set the time of the previous block +func (k Keeper) SetPreviousBlockTime(ctx sdk.Context, blockTime time.Time) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousBlockTimeKey) + store.Set([]byte{}, k.cdc.MustMarshalBinaryBare(blockTime)) +} diff --git a/x/incentive/keeper/params.go b/x/incentive/keeper/params.go new file mode 100644 index 00000000..ad80a0e8 --- /dev/null +++ b/x/incentive/keeper/params.go @@ -0,0 +1,18 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/types" +) + +// GetParams returns the params from the store +func (k Keeper) GetParams(ctx sdk.Context) types.Params { + var p types.Params + k.paramSubspace.GetParamSet(ctx, &p) + return p +} + +// SetParams sets params on the store +func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { + k.paramSubspace.SetParamSet(ctx, ¶ms) +} diff --git a/x/incentive/keeper/payout.go b/x/incentive/keeper/payout.go new file mode 100644 index 00000000..ac3142b8 --- /dev/null +++ b/x/incentive/keeper/payout.go @@ -0,0 +1,204 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/x/auth" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/cosmos/cosmos-sdk/x/auth/vesting" + supplyExported "github.com/cosmos/cosmos-sdk/x/supply/exported" + "github.com/kava-labs/kava/x/incentive/types" + validatorvesting "github.com/kava-labs/kava/x/validator-vesting" +) + +// PayoutClaim sends the timelocked claim coins to the input address +func (k Keeper) PayoutClaim(ctx sdk.Context, addr sdk.AccAddress, denom string, id uint64) error { + claim, found := k.GetClaim(ctx, addr, denom, id) + if !found { + return sdkerrors.Wrapf(types.ErrClaimNotFound, "id: %d, denom %s, address: %s", id, denom, addr) + } + claimPeriod, found := k.GetClaimPeriod(ctx, id, denom) + if !found { + return sdkerrors.Wrapf(types.ErrClaimPeriodNotFound, "id: %d, denom: %s", id, denom) + } + err := k.SendTimeLockedCoinsToAccount(ctx, types.IncentiveMacc, addr, sdk.NewCoins(claim.Reward), int64(claimPeriod.TimeLock.Seconds())) + if err != nil { + return err + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeClaim, + sdk.NewAttribute(types.AttributeKeySender, fmt.Sprintf("%s", addr)), + ), + ) + return nil +} + +// SendTimeLockedCoinsToAccount sends time-locked coins from the input module account to the recipient. If the recipients account is not a vesting account, it is converted to a periodic vesting account and the coins are added to the vesting balance as a vesting period with the input length. +func (k Keeper) SendTimeLockedCoinsToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins, length int64) error { + macc := k.supplyKeeper.GetModuleAccount(ctx, senderModule) + if !macc.GetCoins().IsAllGTE(amt) { + return sdkerrors.Wrapf(types.ErrInsufficientModAccountBalance, "%s", senderModule) + } + + // 0. Get the account from the account keeper and do a type switch, error if it's a validator vesting account or module account (can make this work for validator vesting later if necessary) + acc := k.accountKeeper.GetAccount(ctx, recipientAddr) + + switch acc.(type) { + case *validatorvesting.ValidatorVestingAccount, supplyExported.ModuleAccountI: + return sdkerrors.Wrapf(types.ErrInvalidAccountType, "%T", acc) + case *vesting.PeriodicVestingAccount: + return k.SendTimeLockedCoinsToPeriodicVestingAccount(ctx, senderModule, recipientAddr, amt, length) + case *auth.BaseAccount: + return k.SendTimeLockedCoinsToBaseAccount(ctx, senderModule, recipientAddr, amt, length) + default: + return sdkerrors.Wrapf(types.ErrInvalidAccountType, "%T", acc) + } +} + +// SendTimeLockedCoinsToPeriodicVestingAccount sends time-locked coins from the input module account to the recipient +func (k Keeper) SendTimeLockedCoinsToPeriodicVestingAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins, length int64) error { + err := k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, senderModule, recipientAddr, amt) + if err != nil { + return err + } + k.addCoinsToVestingSchedule(ctx, recipientAddr, amt, length) + return nil +} + +// SendTimeLockedCoinsToBaseAccount sends time-locked coins from the input module account to the recipient, converting the recipient account to a vesting account +func (k Keeper) SendTimeLockedCoinsToBaseAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins, length int64) error { + err := k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, senderModule, recipientAddr, amt) + if err != nil { + return err + } + acc := k.accountKeeper.GetAccount(ctx, recipientAddr) + // transition the account to a periodic vesting account: + bacc := authtypes.NewBaseAccount(acc.GetAddress(), acc.GetCoins(), acc.GetPubKey(), acc.GetAccountNumber(), acc.GetSequence()) + newPeriods := vesting.Periods{types.NewPeriod(amt, length)} + bva, err := vesting.NewBaseVestingAccount(bacc, amt, ctx.BlockTime().Unix()+length) + if err != nil { + return err + } + pva := vesting.NewPeriodicVestingAccountRaw(bva, ctx.BlockTime().Unix(), newPeriods) + k.accountKeeper.SetAccount(ctx, pva) + return nil +} + +// DeleteExpiredClaimsAndClaimPeriods deletes expired claim periods and their associated claims +func (k Keeper) DeleteExpiredClaimsAndClaimPeriods(ctx sdk.Context) { + k.IterateClaimPeriods(ctx, func(cp types.ClaimPeriod) (stop bool) { + if !cp.End.Before(ctx.BlockTime()) { + return false + } + k.IterateClaims(ctx, func(c types.Claim) (stop bool) { + if !(c.Denom == cp.Denom && c.ClaimPeriodID == cp.ID) { + return false + } + k.DeleteClaim(ctx, c.Owner, c.Denom, c.ClaimPeriodID) + return false + }) + k.DeleteClaimPeriod(ctx, cp.ID, cp.Denom) + return false + }) +} + +// GetClaimsByAddressAndDenom returns all claims for a specific user and address and a bool for if any were found +func (k Keeper) GetClaimsByAddressAndDenom(ctx sdk.Context, addr sdk.AccAddress, denom string) (claims types.Claims, found bool) { + found = false + k.IterateClaimPeriods(ctx, func(cp types.ClaimPeriod) (stop bool) { + if cp.Denom != denom { + return false + } + c, hasClaim := k.GetClaim(ctx, addr, cp.Denom, cp.ID) + if !hasClaim { + return false + } + found = true + claims = append(claims, c) + return false + }) + return claims, found +} + +// addCoinsToVestingSchedule adds coins to the input account's vesting schedule where length is the amount of time (from the current block time), in seconds, that the coins will be vesting for +// the input address must be a periodic vesting account +func (k Keeper) addCoinsToVestingSchedule(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins, length int64) { + acc := k.accountKeeper.GetAccount(ctx, addr) + vacc := acc.(*vesting.PeriodicVestingAccount) + // Add the new vesting coins to OriginalVesting + vacc.OriginalVesting = vacc.OriginalVesting.Add(amt...) + // update vesting periods + if vacc.EndTime < ctx.BlockTime().Unix() { + // edge case one - the vesting account's end time is in the past (ie, all previous vesting periods have completed) + // append a new period to the vesting account, update the end time, update the account in the store and return + newPeriodLength := (ctx.BlockTime().Unix() - vacc.EndTime) + length + newPeriod := types.NewPeriod(amt, newPeriodLength) + vacc.VestingPeriods = append(vacc.VestingPeriods, newPeriod) + vacc.EndTime = ctx.BlockTime().Unix() + length + k.accountKeeper.SetAccount(ctx, vacc) + return + } + if vacc.StartTime > ctx.BlockTime().Unix() { + // edge case two - the vesting account's start time is in the future (all periods have not started) + // update the start time to now and adjust the period lengths in place - a new period will be inserted in the next code block + updatedPeriods := vesting.Periods{} + for i, period := range vacc.VestingPeriods { + updatedPeriod := period + if i == 0 { + updatedPeriod = types.NewPeriod(period.Amount, (vacc.StartTime-ctx.BlockTime().Unix())+period.Length) + } + updatedPeriods = append(updatedPeriods, updatedPeriod) + } + vacc.VestingPeriods = updatedPeriods + vacc.StartTime = ctx.BlockTime().Unix() + } + + // logic for inserting a new vesting period into the existing vesting schedule + totalPeriodLength := types.GetTotalVestingPeriodLength(vacc.VestingPeriods) + proposedEndTime := ctx.BlockTime().Unix() + length + if totalPeriodLength < length { + // in the case that the proposed length is longer than the sum of all previous period lengths, create a new period with length equal to the difference between the proposed length and the previous total length + newPeriodLength := length - totalPeriodLength + newPeriod := types.NewPeriod(amt, newPeriodLength) + vacc.VestingPeriods = append(vacc.VestingPeriods, newPeriod) + // update the end time so that the sum of all period lengths equals endTime - startTime + vacc.EndTime = proposedEndTime + } else { + // In the case that the proposed length is less than or equal to the sum of all previous period lengths, insert the period and update other periods as necessary. + // EXAMPLE (l is length, a is amount) + // Original Periods: {[l: 1 a: 1], [l: 2, a: 1], [l:8, a:3], [l: 5, a: 3]} + // Period we want to insert [l: 5, a: x] + // Expected result: + // {[l: 1, a: 1], [l:2, a: 1], [l:2, a:x], [l:6, a:3], [l:5, a:3]} + + newPeriods := vesting.Periods{} + lengthCounter := int64(0) + appendRemaining := false + for _, period := range vacc.VestingPeriods { + if appendRemaining { + newPeriods = append(newPeriods, period) + continue + } + lengthCounter += period.Length + if lengthCounter < length { + newPeriods = append(newPeriods, period) + } else if lengthCounter == length { + newPeriod := types.NewPeriod(period.Amount.Add(amt...), period.Length) + newPeriods = append(newPeriods, newPeriod) + appendRemaining = true + } else { + newPeriod := types.NewPeriod(amt, length-types.GetTotalVestingPeriodLength(newPeriods)) + previousPeriod := types.NewPeriod(period.Amount, period.Length-newPeriod.Length) + newPeriods = append(newPeriods, newPeriod, previousPeriod) + appendRemaining = true + } + } + vacc.VestingPeriods = newPeriods + } + k.accountKeeper.SetAccount(ctx, vacc) + return +} diff --git a/x/incentive/keeper/querier.go b/x/incentive/keeper/querier.go new file mode 100644 index 00000000..f5a3654c --- /dev/null +++ b/x/incentive/keeper/querier.go @@ -0,0 +1,51 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/kava-labs/kava/x/incentive/types" + abci "github.com/tendermint/tendermint/abci/types" +) + +// NewQuerier is the module level router for state queries +func NewQuerier(k Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err error) { + switch path[0] { + case types.QueryGetParams: + return queryGetParams(ctx, req, k) + case types.QueryGetClaims: + return queryGetClaims(ctx, req, k) + default: + return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unknown %s query endpoint", types.ModuleName) + } + } +} + +// query params in the store +func queryGetParams(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + // Get params + params := k.GetParams(ctx) + + // Encode results + bz, err := codec.MarshalJSONIndent(k.cdc, params) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} + +func queryGetClaims(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + var requestParams types.QueryClaimsParams + err := k.cdc.UnmarshalJSON(req.Data, &requestParams) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONUnmarshal, err.Error()) + } + claims, _ := k.GetClaimsByAddressAndDenom(ctx, requestParams.Owner, requestParams.Denom) + + bz, err := codec.MarshalJSONIndent(k.cdc, claims) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} diff --git a/x/incentive/keeper/rewards.go b/x/incentive/keeper/rewards.go new file mode 100644 index 00000000..116c8755 --- /dev/null +++ b/x/incentive/keeper/rewards.go @@ -0,0 +1,110 @@ +package keeper + +import ( + "time" + + "github.com/cosmos/cosmos-sdk/store/prefix" + sdk "github.com/cosmos/cosmos-sdk/types" + cdptypes "github.com/kava-labs/kava/x/cdp/types" + "github.com/kava-labs/kava/x/incentive/types" +) + +// HandleRewardPeriodExpiry deletes expired RewardPeriods from the store and creates a ClaimPeriod in the store for each expired RewardPeriod +func (k Keeper) HandleRewardPeriodExpiry(ctx sdk.Context, rp types.RewardPeriod) { + k.CreateUniqueClaimPeriod(ctx, rp.Denom, rp.ClaimEnd, rp.ClaimTimeLock) + store := prefix.NewStore(ctx.KVStore(k.key), types.RewardPeriodKeyPrefix) + store.Delete([]byte(rp.Denom)) + return +} + +// CreateNewRewardPeriod creates a new reward period from the input reward +func (k Keeper) CreateNewRewardPeriod(ctx sdk.Context, reward types.Reward) { + // reward periods store the amount of rewards payed PER SECOND + rewardsPerSecond := sdk.NewDecFromInt(reward.AvailableRewards.Amount).Quo(sdk.NewDecFromInt(sdk.NewInt(int64(reward.Duration.Seconds())))).TruncateInt() + rewardCoinPerSecond := sdk.NewCoin(reward.AvailableRewards.Denom, rewardsPerSecond) + rp := types.RewardPeriod{ + Denom: reward.Denom, + Start: ctx.BlockTime(), + End: ctx.BlockTime().Add(reward.Duration), + Reward: rewardCoinPerSecond, + ClaimEnd: ctx.BlockTime().Add(reward.Duration).Add(reward.ClaimDuration), + ClaimTimeLock: reward.TimeLock, + } + k.SetRewardPeriod(ctx, rp) +} + +// CreateAndDeleteRewardPeriods creates reward periods for active rewards that don't already have a reward period and deletes reward periods for inactive rewards that currently have a reward period +func (k Keeper) CreateAndDeleteRewardPeriods(ctx sdk.Context) { + params := k.GetParams(ctx) + + for _, r := range params.Rewards { + _, found := k.GetRewardPeriod(ctx, r.Denom) + // if governance has made a reward inactive, delete the current period + if found && !r.Active { + k.DeleteRewardPeriod(ctx, r.Denom) + } + // if a reward period for an active reward is not found, create one + if !found && r.Active { + k.CreateNewRewardPeriod(ctx, r) + } + } +} + +// ApplyRewardsToCdps iterates over the reward periods and creates a claim for each cdp owner that created usdx with the collateral specified in the reward period +func (k Keeper) ApplyRewardsToCdps(ctx sdk.Context) { + previousBlockTime, found := k.GetPreviousBlockTime(ctx) + if !found { + previousBlockTime = ctx.BlockTime() + k.SetPreviousBlockTime(ctx, previousBlockTime) + return + } + k.IterateRewardPeriods(ctx, func(rp types.RewardPeriod) bool { + expired := false + // the total amount of usdx created with the collateral type being incentivized + totalPrincipal := k.cdpKeeper.GetTotalPrincipal(ctx, rp.Denom, types.PrincipalDenom) + // the number of seconds since last payout + timeElapsed := sdk.NewInt(ctx.BlockTime().Unix() - previousBlockTime.Unix()) + if rp.End.Before(ctx.BlockTime()) { + timeElapsed = sdk.NewInt(rp.End.Unix() - previousBlockTime.Unix()) + expired = true + } + // the amount of rewards to pay (rewardAmount * timeElapsed) + rewardsThisPeriod := rp.Reward.Amount.Mul(timeElapsed) + id := k.GetNextClaimPeriodID(ctx, rp.Denom) + k.cdpKeeper.IterateCdpsByDenom(ctx, rp.Denom, func(cdp cdptypes.CDP) bool { + rewardsShare := sdk.NewDecFromInt(cdp.Principal.AmountOf(types.PrincipalDenom).Add(cdp.AccumulatedFees.AmountOf(types.PrincipalDenom))).Quo(sdk.NewDecFromInt(totalPrincipal)) + // sanity check - don't create zero claims + if rewardsShare.IsZero() { + return false + } + rewardsEarned := rewardsShare.Mul(sdk.NewDecFromInt(rewardsThisPeriod)).RoundInt() + k.AddToClaim(ctx, cdp.Owner, rp.Denom, id, sdk.NewCoin(types.GovDenom, rewardsEarned)) + return false + }) + if !expired { + return false + } + k.HandleRewardPeriodExpiry(ctx, rp) + return false + }) + k.SetPreviousBlockTime(ctx, ctx.BlockTime()) +} + +// CreateUniqueClaimPeriod creates a new claim period in the store and updates the highest claim period id +func (k Keeper) CreateUniqueClaimPeriod(ctx sdk.Context, denom string, end time.Time, timeLock time.Duration) { + id := k.GetNextClaimPeriodID(ctx, denom) + claimPeriod := types.NewClaimPeriod(denom, id, end, timeLock) + k.SetClaimPeriod(ctx, claimPeriod) + k.SetNextClaimPeriodID(ctx, denom, id+1) +} + +// AddToClaim adds the amount to an existing claim or creates a new one for that amount +func (k Keeper) AddToClaim(ctx sdk.Context, addr sdk.AccAddress, denom string, id uint64, amount sdk.Coin) { + claim, found := k.GetClaim(ctx, addr, denom, id) + if found { + claim.Reward = claim.Reward.Add(amount) + } else { + claim = types.NewClaim(addr, amount, denom, id) + } + k.SetClaim(ctx, claim) +} diff --git a/x/incentive/module.go b/x/incentive/module.go new file mode 100644 index 00000000..623f883e --- /dev/null +++ b/x/incentive/module.go @@ -0,0 +1,164 @@ +package incentive + +import ( + "encoding/json" + "math/rand" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + sim "github.com/cosmos/cosmos-sdk/x/simulation" + "github.com/gorilla/mux" + "github.com/kava-labs/kava/x/incentive/client/cli" + "github.com/kava-labs/kava/x/incentive/client/rest" + "github.com/kava-labs/kava/x/incentive/keeper" + "github.com/kava-labs/kava/x/incentive/simulation" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/spf13/cobra" + abci "github.com/tendermint/tendermint/abci/types" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModuleSimulation = AppModule{} +) + +// AppModuleBasic defines the basic application module used by the incentive module. +type AppModuleBasic struct{} + +// Name returns the incentive module's name. +func (AppModuleBasic) Name() string { + return types.ModuleName +} + +// RegisterCodec registers the incentive module's types for the given codec. +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + types.RegisterCodec(cdc) +} + +// DefaultGenesis returns default genesis state as raw bytes for the incentive +// module. +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return types.ModuleCdc.MustMarshalJSON(types.DefaultGenesisState()) +} + +// ValidateGenesis performs genesis state validation for the incentive module. +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var gs types.GenesisState + err := types.ModuleCdc.UnmarshalJSON(bz, &gs) + if err != nil { + return err + } + return gs.Validate() +} + +// RegisterRESTRoutes registers the REST routes for the incentive module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the incentive module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns no root query command for the crisis module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(types.StoreKey, cdc) +} + +// RegisterStoreDecoder registers a decoder for cdp module's types +func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { + sdr[StoreKey] = simulation.DecodeStore +} + +// GenerateGenesisState creates a randomized GenState of the cdp module +func (AppModuleBasic) GenerateGenesisState(simState *module.SimulationState) { + simulation.RandomizedGenState(simState) +} + +// RandomizedParams creates randomized cdp param changes for the simulator. +func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { + return simulation.ParamChanges(r) +} + +// ProposalContents doesn't return any content functions for governance proposals. +func (AppModuleBasic) ProposalContents(_ module.SimulationState) []sim.WeightedProposalContent { + return nil +} + +// WeightedOperations returns the all the bep3 module operations with their respective weights. +func (am AppModule) WeightedOperations(_ module.SimulationState) []sim.WeightedOperation { + return nil +} + +// AppModule implements the sdk.AppModule interface. +type AppModule struct { + AppModuleBasic + + keeper keeper.Keeper + supplyKeeper types.SupplyKeeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper keeper.Keeper, supplyKeeper types.SupplyKeeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + supplyKeeper: supplyKeeper, + } +} + +// Name returns the incentive module's name. +func (AppModule) Name() string { + return types.ModuleName +} + +// RegisterInvariants registers the incentive module invariants. +func (am AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route returns the message routing key for the incentive module. +func (AppModule) Route() string { + return types.RouterKey +} + +// NewHandler returns an sdk.Handler for the incentive module. +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute returns the incentive module's querier route name. +func (AppModule) QuerierRoute() string { + return types.QuerierRoute +} + +// NewQuerierHandler returns the incentive module sdk.Querier. +func (am AppModule) NewQuerierHandler() sdk.Querier { + return keeper.NewQuerier(am.keeper) +} + +// InitGenesis performs genesis initialization for the incentive module. It returns no validator updates. +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var gs types.GenesisState + types.ModuleCdc.MustUnmarshalJSON(data, &gs) + InitGenesis(ctx, am.keeper, am.supplyKeeper, gs) + return []abci.ValidatorUpdate{} +} + +// ExportGenesis returns the exported genesis state as raw bytes for the incentive module +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return types.ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock returns the begin blocker for the incentive module. +func (am AppModule) BeginBlock(ctx sdk.Context, req abci.RequestBeginBlock) { + BeginBlocker(ctx, am.keeper) +} + +// EndBlock returns the end blocker for the incentive module. It returns no validator updates. +func (am AppModule) EndBlock(_ sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return []abci.ValidatorUpdate{} +} diff --git a/x/incentive/simulation/decoder.go b/x/incentive/simulation/decoder.go new file mode 100644 index 00000000..ace82b35 --- /dev/null +++ b/x/incentive/simulation/decoder.go @@ -0,0 +1,12 @@ +package simulation + +import ( + "github.com/cosmos/cosmos-sdk/codec" + "github.com/tendermint/tendermint/libs/kv" +) + +// DecodeStore unmarshals the KVPair's Value to the corresponding incentive type +func DecodeStore(cdc *codec.Codec, kvA, kvB kv.Pair) string { + // TODO implement this + return "" +} diff --git a/x/incentive/simulation/params.go b/x/incentive/simulation/params.go new file mode 100644 index 00000000..8c1f7aff --- /dev/null +++ b/x/incentive/simulation/params.go @@ -0,0 +1,14 @@ +package simulation + +import ( + "math/rand" + + "github.com/cosmos/cosmos-sdk/x/simulation" +) + +// ParamChanges defines the parameters that can be modified by param change proposals +// on the simulation +func ParamChanges(r *rand.Rand) []simulation.ParamChange { + // TODO implement this + return []simulation.ParamChange{} +} diff --git a/x/incentive/simulation/simulation.go b/x/incentive/simulation/simulation.go new file mode 100644 index 00000000..3057550f --- /dev/null +++ b/x/incentive/simulation/simulation.go @@ -0,0 +1,22 @@ +package simulation + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/types/module" + + "github.com/kava-labs/kava/x/incentive/types" +) + +// RandomizedGenState generates a random GenesisState for cdp +func RandomizedGenState(simState *module.SimulationState) { + + // TODO implement this fully + // - randomly generating the genesis params + // - overwriting with genesis provided to simulation + genesis := types.DefaultGenesisState() + + fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, codec.MustMarshalJSONIndent(simState.Cdc, genesis)) + simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(genesis) +} diff --git a/x/incentive/types/account.go b/x/incentive/types/account.go new file mode 100644 index 00000000..d651d7ee --- /dev/null +++ b/x/incentive/types/account.go @@ -0,0 +1,14 @@ +package types + +import ( + "github.com/cosmos/cosmos-sdk/x/auth/vesting" +) + +// GetTotalVestingPeriodLength returns the summed length of all vesting periods +func GetTotalVestingPeriodLength(periods vesting.Periods) int64 { + length := int64(0) + for _, period := range periods { + length += period.Length + } + return length +} diff --git a/x/incentive/types/account_test.go b/x/incentive/types/account_test.go new file mode 100644 index 00000000..525a3d02 --- /dev/null +++ b/x/incentive/types/account_test.go @@ -0,0 +1,51 @@ +package types_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth/vesting" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/stretchr/testify/suite" +) + +type accountTest struct { + periods vesting.Periods + expectedVal int64 +} + +type AccountTestSuite struct { + suite.Suite + + tests []accountTest +} + +func (suite *AccountTestSuite) SetupTest() { + tests := []accountTest{ + accountTest{ + periods: vesting.Periods{ + vesting.Period{ + Length: int64(100), + Amount: sdk.Coins{}, + }, + vesting.Period{ + Length: int64(200), + Amount: sdk.Coins{}, + }, + }, + expectedVal: int64(300), + }, + } + suite.tests = tests +} + +func (suite *AccountTestSuite) TestGetTotalPeriodLength() { + for _, t := range suite.tests { + length := types.GetTotalVestingPeriodLength(t.periods) + suite.Equal(t.expectedVal, length) + } +} + +func TestAccountTestSuite(t *testing.T) { + suite.Run(t, new(AccountTestSuite)) +} diff --git a/x/incentive/types/codec.go b/x/incentive/types/codec.go new file mode 100644 index 00000000..31f066e0 --- /dev/null +++ b/x/incentive/types/codec.go @@ -0,0 +1,22 @@ +package types + +import "github.com/cosmos/cosmos-sdk/codec" + +// ModuleCdc generic sealed codec to be used throughout module +var ModuleCdc *codec.Codec + +func init() { + cdc := codec.New() + RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + ModuleCdc = cdc.Seal() +} + +// RegisterCodec registers the necessary types for incentive module +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgClaimReward{}, "incentive/MsgClaimReward", nil) + cdc.RegisterConcrete(GenesisClaimPeriodID{}, "incentive/GenesisClaimPeriodID", nil) + cdc.RegisterConcrete(RewardPeriod{}, "incentive/RewardPeriod", nil) + cdc.RegisterConcrete(ClaimPeriod{}, "incentive/ClaimPeriod", nil) + cdc.RegisterConcrete(Claim{}, "incentive/Claim", nil) +} diff --git a/x/incentive/types/errors.go b/x/incentive/types/errors.go new file mode 100644 index 00000000..f42cb5b6 --- /dev/null +++ b/x/incentive/types/errors.go @@ -0,0 +1,15 @@ +package types + +import ( + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// DONTCOVER + +var ( + ErrClaimNotFound = sdkerrors.Register(ModuleName, 1, "no claim with input id found for owner and denom") + ErrClaimPeriodNotFound = sdkerrors.Register(ModuleName, 2, "no claim period found for id and denom") + ErrInvalidAccountType = sdkerrors.Register(ModuleName, 3, "account type not supported") + ErrNoClaimsFound = sdkerrors.Register(ModuleName, 4, "no claims with denom found for address") + ErrInsufficientModAccountBalance = sdkerrors.Register(ModuleName, 5, "module account has insufficient balance to pay claim") +) diff --git a/x/incentive/types/events.go b/x/incentive/types/events.go new file mode 100644 index 00000000..09d0cd47 --- /dev/null +++ b/x/incentive/types/events.go @@ -0,0 +1,8 @@ +package types + +const ( + EventTypeClaim = "claim_reward" + + AttributeValueCategory = ModuleName + AttributeKeySender = "sender" +) diff --git a/x/incentive/types/expected_keepers.go b/x/incentive/types/expected_keepers.go new file mode 100644 index 00000000..ccb20830 --- /dev/null +++ b/x/incentive/types/expected_keepers.go @@ -0,0 +1,27 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" + cdptypes "github.com/kava-labs/kava/x/cdp/types" +) + +// SupplyKeeper defines the expected supply keeper for module accounts +type SupplyKeeper interface { + GetModuleAccount(ctx sdk.Context, name string) supplyexported.ModuleAccountI + + SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error +} + +// CdpKeeper defines the expected cdp keeper for interacting with cdps +type CdpKeeper interface { + IterateCdpsByDenom(ctx sdk.Context, denom string, cb func(cdp cdptypes.CDP) (stop bool)) + GetTotalPrincipal(ctx sdk.Context, collateralDenom string, principalDenom string) (total sdk.Int) +} + +// AccountKeeper defines the expected keeper interface for interacting with account +type AccountKeeper interface { + GetAccount(ctx sdk.Context, addr sdk.AccAddress) authexported.Account + SetAccount(ctx sdk.Context, acc authexported.Account) +} diff --git a/x/incentive/types/genesis.go b/x/incentive/types/genesis.go new file mode 100644 index 00000000..d73d1b5e --- /dev/null +++ b/x/incentive/types/genesis.go @@ -0,0 +1,75 @@ +package types + +import ( + "bytes" + "fmt" + "time" +) + +// GenesisClaimPeriodID stores the next claim id and its corresponding denom +type GenesisClaimPeriodID struct { + Denom string `json:"denom" yaml:"denom"` + ID uint64 `json:"id" yaml:"id"` +} + +// GenesisClaimPeriodIDs array of GenesisClaimPeriodID +type GenesisClaimPeriodIDs []GenesisClaimPeriodID + +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Params Params `json:"params" yaml:"params"` + PreviousBlockTime time.Time `json:"previous_block_time" yaml:"previous_block_time"` + RewardPeriods RewardPeriods `json:"reward_periods" yaml:"reward_periods"` + ClaimPeriods ClaimPeriods `json:"claim_periods" yaml:"claim_periods"` + Claims Claims `json:"claims" yaml:"claims"` + NextClaimPeriodIDs GenesisClaimPeriodIDs `json:"next_claim_period_ids" yaml:"next_claim_period_ids"` +} + +// NewGenesisState returns a new genesis state +func NewGenesisState(params Params, previousBlockTime time.Time, rp RewardPeriods, cp ClaimPeriods, c Claims, ids GenesisClaimPeriodIDs) GenesisState { + return GenesisState{ + Params: params, + PreviousBlockTime: previousBlockTime, + RewardPeriods: rp, + ClaimPeriods: cp, + Claims: c, + NextClaimPeriodIDs: ids, + } +} + +// DefaultGenesisState returns a default genesis state +func DefaultGenesisState() GenesisState { + return GenesisState{ + Params: DefaultParams(), + PreviousBlockTime: DefaultPreviousBlockTime, + RewardPeriods: RewardPeriods{}, + ClaimPeriods: ClaimPeriods{}, + Claims: Claims{}, + NextClaimPeriodIDs: GenesisClaimPeriodIDs{}, + } +} + +// Validate performs basic validation of genesis data returning an +// error for any failed validation criteria. +func (gs GenesisState) Validate() error { + + if err := gs.Params.Validate(); err != nil { + return err + } + if gs.PreviousBlockTime.Equal(time.Time{}) { + return fmt.Errorf("previous block time not set") + } + return nil +} + +// Equal checks whether two gov GenesisState structs are equivalent +func (gs GenesisState) Equal(gs2 GenesisState) bool { + b1 := ModuleCdc.MustMarshalBinaryBare(gs) + b2 := ModuleCdc.MustMarshalBinaryBare(gs2) + return bytes.Equal(b1, b2) +} + +// IsEmpty returns true if a GenesisState is empty +func (gs GenesisState) IsEmpty() bool { + return gs.Equal(GenesisState{}) +} diff --git a/x/incentive/types/keys.go b/x/incentive/types/keys.go new file mode 100644 index 00000000..1185ff1d --- /dev/null +++ b/x/incentive/types/keys.go @@ -0,0 +1,61 @@ +package types + +import ( + "encoding/binary" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const ( + // ModuleName The name that will be used throughout the module + ModuleName = "incentive" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName + + // QuerierRoute route used for abci queries + QuerierRoute = ModuleName +) + +// Key Prefixes +var ( + RewardPeriodKeyPrefix = []byte{0x01} // prefix for keys that store reward periods + ClaimPeriodKeyPrefix = []byte{0x02} // prefix for keys that store claim periods + ClaimKeyPrefix = []byte{0x03} // prefix for keys that store claims + NextClaimPeriodIDPrefix = []byte{0x04} // prefix for keys that store the next ID for claims periods + PreviousBlockTimeKey = []byte{0x05} // prefix for key that stores the previous blocktime +) + +// Keys +// 0x00:Denom <> RewardPeriod the current active reward period (max 1 reward period per denom) +// 0x01:Denom:ID <> ClaimPeriod object for that ID, indexed by denom and ID +// 0x02:Denom:ID:Owner <> Claim object, indexed by Denom, ID and owner +// 0x03:Denom <> NextClaimPeriodIDPrefix the ID of the next claim period, indexed by denom + +// BytesToUint64 returns uint64 format from a byte array +func BytesToUint64(bz []byte) uint64 { + return binary.BigEndian.Uint64(bz) +} + +// GetClaimPeriodPrefix returns the key (denom + id) for a claim prefix +func GetClaimPeriodPrefix(denom string, id uint64) []byte { + return createKey([]byte(denom), sdk.Uint64ToBigEndian(id)) +} + +// GetClaimPrefix returns the key (denom + id + address) for a claim +func GetClaimPrefix(addr sdk.AccAddress, denom string, id uint64) []byte { + return createKey([]byte(denom), sdk.Uint64ToBigEndian(id), addr) +} + +func createKey(bytes ...[]byte) (r []byte) { + for _, b := range bytes { + r = append(r, b...) + } + return +} diff --git a/x/incentive/types/msg.go b/x/incentive/types/msg.go new file mode 100644 index 00000000..446389d5 --- /dev/null +++ b/x/incentive/types/msg.go @@ -0,0 +1,54 @@ +package types + +import ( + "errors" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// ensure Msg interface compliance at compile time +var _ sdk.Msg = &MsgClaimReward{} + +// MsgClaimReward message type used to claim rewards +type MsgClaimReward struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` +} + +// NewMsgClaimReward returns a new MsgClaimReward. +func NewMsgClaimReward(sender sdk.AccAddress, denom string) MsgClaimReward { + return MsgClaimReward{ + Sender: sender, + Denom: denom, + } +} + +// Route return the message type used for routing the message. +func (msg MsgClaimReward) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgClaimReward) Type() string { return "claim_reward" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgClaimReward) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + if strings.TrimSpace(msg.Denom) == "" { + return errors.New("invalid (empty) denom") + } + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgClaimReward) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgClaimReward) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} diff --git a/x/incentive/types/msg_test.go b/x/incentive/types/msg_test.go new file mode 100644 index 00000000..0d77c94e --- /dev/null +++ b/x/incentive/types/msg_test.go @@ -0,0 +1,59 @@ +package types_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/stretchr/testify/suite" + "github.com/tendermint/tendermint/crypto" +) + +type msgTest struct { + from sdk.AccAddress + denom string + expectPass bool +} + +type MsgTestSuite struct { + suite.Suite + + tests []msgTest +} + +func (suite *MsgTestSuite) SetupTest() { + tests := []msgTest{ + msgTest{ + from: sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + denom: "bnb", + expectPass: true, + }, + msgTest{ + from: sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + denom: "", + expectPass: false, + }, + msgTest{ + from: sdk.AccAddress{}, + denom: "bnb", + expectPass: false, + }, + } + suite.tests = tests +} + +func (suite *MsgTestSuite) TestMsgValidation() { + for _, t := range suite.tests { + msg := types.NewMsgClaimReward(t.from, t.denom) + err := msg.ValidateBasic() + if t.expectPass { + suite.NoError(err) + } else { + suite.Error(err) + } + } +} + +func TestMsgTestSuite(t *testing.T) { + suite.Run(t, new(MsgTestSuite)) +} diff --git a/x/incentive/types/params.go b/x/incentive/types/params.go new file mode 100644 index 00000000..555beebc --- /dev/null +++ b/x/incentive/types/params.go @@ -0,0 +1,165 @@ +package types + +import ( + "fmt" + "strings" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params" + cdptypes "github.com/kava-labs/kava/x/cdp/types" + kavadistTypes "github.com/kava-labs/kava/x/kavadist/types" + tmtime "github.com/tendermint/tendermint/types/time" +) + +// Parameter keys and default values +var ( + KeyActive = []byte("Active") + KeyRewards = []byte("Rewards") + DefaultActive = false + DefaultRewards = Rewards{} + DefaultPreviousBlockTime = tmtime.Canonical(time.Unix(0, 0)) + GovDenom = cdptypes.DefaultGovDenom + PrincipalDenom = "usdx" + IncentiveMacc = kavadistTypes.ModuleName +) + +// Params governance parameters for the incentive module +type Params struct { + Active bool `json:"active" yaml:"active"` // top level governance switch to disable all rewards + Rewards Rewards `json:"rewards" yaml:"rewards"` +} + +// NewParams returns a new params object +func NewParams(active bool, rewards Rewards) Params { + return Params{ + Active: active, + Rewards: rewards, + } +} + +// DefaultParams returns default params for kavadist module +func DefaultParams() Params { + return NewParams(DefaultActive, DefaultRewards) +} + +// String implements fmt.Stringer +func (p Params) String() string { + return fmt.Sprintf(`Params: + Active: %t + Rewards: %s`, p.Active, p.Rewards) +} + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable().RegisterParamSet(&Params{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +func (p *Params) ParamSetPairs() params.ParamSetPairs { + return params.ParamSetPairs{ + params.NewParamSetPair(KeyActive, &p.Active, validateActiveParam), + params.NewParamSetPair(KeyRewards, &p.Rewards, validateRewardsParam), + } +} + +// Validate checks that the parameters have valid values. +func (p Params) Validate() error { + if err := validateActiveParam(p.Active); err != nil { + return err + } + + if err := validateRewardsParam(p.Rewards); err != nil { + return err + } + + return nil +} + +func validateActiveParam(i interface{}) error { + _, ok := i.(bool) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + return nil +} + +func validateRewardsParam(i interface{}) error { + rewards, ok := i.(Rewards) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + rewardDenoms := make(map[string]bool) + + for _, reward := range rewards { + if strings.TrimSpace(reward.Denom) == "" { + return fmt.Errorf("cannot have empty reward denom: %s", reward) + } + if rewardDenoms[reward.Denom] { + return fmt.Errorf("cannot have duplicate reward denoms: %s", reward.Denom) + } + rewardDenoms[reward.Denom] = true + if !reward.AvailableRewards.IsValid() { + return fmt.Errorf("invalid reward coins %s for %s", reward.AvailableRewards, reward.Denom) + } + if !reward.AvailableRewards.IsPositive() { + return fmt.Errorf("reward amount must be positive, is %s for %s", reward.AvailableRewards, reward.Denom) + } + if int(reward.Duration.Seconds()) <= 0 { + return fmt.Errorf("reward duration must be positive, is %s for %s", reward.Duration.String(), reward.Denom) + } + if int(reward.TimeLock.Seconds()) < 0 { + return fmt.Errorf("reward timelock must be non-negative, is %s for %s", reward.TimeLock.String(), reward.Denom) + } + if int(reward.ClaimDuration.Seconds()) <= 0 { + return fmt.Errorf("reward timelock must be positive, is %s for %s", reward.ClaimDuration.String(), reward.Denom) + } + } + return nil +} + +// Reward stores the specified state for a single reward period. +type Reward struct { + Active bool `json:"active" yaml:"active"` // governance switch to disable a period + Denom string `json:"denom" yaml:"denom"` // the collateral denom rewards apply to, must be found in the cdp collaterals + AvailableRewards sdk.Coin `json:"available_rewards" yaml:"available_rewards"` // the total amount of coins distributed per period + Duration time.Duration `json:"duration" yaml:"duration"` // the duration of the period + TimeLock time.Duration `json:"time_lock" yaml:"time_lock"` // how long rewards for this period are timelocked + ClaimDuration time.Duration `json:"claim_duration" yaml:"claim_duration"` // how long users have after the period ends to claim their rewards +} + +// NewReward returns a new Reward +func NewReward(active bool, denom string, reward sdk.Coin, duration time.Duration, timelock time.Duration, claimDuration time.Duration) Reward { + return Reward{ + Active: active, + Denom: denom, + AvailableRewards: reward, + Duration: duration, + TimeLock: timelock, + ClaimDuration: claimDuration, + } +} + +// String implements fmt.Stringer +func (r Reward) String() string { + return fmt.Sprintf(`Reward: + Active: %t, + Denom: %s, + Available Rewards: %s, + Duration: %s, + Time Lock: %s, + Claim Duration: %s`, + r.Active, r.Denom, r.AvailableRewards, r.Duration, r.TimeLock, r.ClaimDuration) +} + +// Rewards array of Reward +type Rewards []Reward + +// String implements fmt.Stringer +func (rs Rewards) String() string { + out := "Rewards\n" + for _, r := range rs { + out += fmt.Sprintf("%s\n", r) + } + return out +} diff --git a/x/incentive/types/params_test.go b/x/incentive/types/params_test.go new file mode 100644 index 00000000..745df28d --- /dev/null +++ b/x/incentive/types/params_test.go @@ -0,0 +1,152 @@ +package types_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/incentive/types" + "github.com/stretchr/testify/suite" +) + +type paramTest struct { + params types.Params + expectPass bool +} + +type ParamTestSuite struct { + suite.Suite + + tests []paramTest +} + +func (suite *ParamTestSuite) SetupTest() { + p1 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 24 * 14, + }, + }, + } + p2 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 24 * 14, + }, + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 24 * 14, + }, + }, + } + p3 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * -24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 24 * 14, + }, + }, + } + p4 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * -8766, + ClaimDuration: time.Hour * 24 * 14, + }, + }, + } + p5 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(10000000000)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 0, + }, + }, + } + p6 := types.Params{ + Active: true, + Rewards: types.Rewards{ + types.Reward{ + Active: true, + Denom: "bnb", + AvailableRewards: sdk.NewCoin("ukava", sdk.NewInt(0)), + Duration: time.Hour * 24 * 7, + TimeLock: time.Hour * 8766, + ClaimDuration: time.Hour * 0, + }, + }, + } + + suite.tests = []paramTest{ + paramTest{ + params: p1, + expectPass: true, + }, + paramTest{ + params: p2, + expectPass: false, + }, + paramTest{ + params: p3, + expectPass: false, + }, + paramTest{ + params: p4, + expectPass: false, + }, + paramTest{ + params: p5, + expectPass: false, + }, + paramTest{ + params: p6, + expectPass: false, + }, + } +} + +func (suite *ParamTestSuite) TestParamValidation() { + for _, t := range suite.tests { + err := t.params.Validate() + if t.expectPass { + suite.NoError(err) + } else { + suite.Error(err) + } + } +} + +func TestParamTestSuite(t *testing.T) { + suite.Run(t, new(ParamTestSuite)) +} diff --git a/x/incentive/types/period.go b/x/incentive/types/period.go new file mode 100644 index 00000000..c2894750 --- /dev/null +++ b/x/incentive/types/period.go @@ -0,0 +1,11 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth/vesting" +) + +// NewPeriod returns a new vesting period +func NewPeriod(amount sdk.Coins, length int64) vesting.Period { + return vesting.Period{Amount: amount, Length: length} +} diff --git a/x/incentive/types/querier.go b/x/incentive/types/querier.go new file mode 100644 index 00000000..d6271048 --- /dev/null +++ b/x/incentive/types/querier.go @@ -0,0 +1,35 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" +) + +// Querier routes for the incentive module +const ( + QueryGetClaims = "claims" + RestClaimOwner = "owner" + RestClaimDenom = "denom" + QueryGetParams = "parameters" +) + +// QueryClaimsParams params for query /incentive/claims +type QueryClaimsParams struct { + Owner sdk.AccAddress + Denom string +} + +// NewQueryClaimsParams returns QueryClaimsParams +func NewQueryClaimsParams(owner sdk.AccAddress, denom string) QueryClaimsParams { + return QueryClaimsParams{ + Owner: owner, + Denom: denom, + } +} + +// PostClaimReq defines the properties of claim transaction's request body. +type PostClaimReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` +} diff --git a/x/incentive/types/rewards.go b/x/incentive/types/rewards.go new file mode 100644 index 00000000..237b222a --- /dev/null +++ b/x/incentive/types/rewards.go @@ -0,0 +1,97 @@ +package types + +import ( + "fmt" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// RewardPeriod stores the state of an ongoing reward +type RewardPeriod struct { + Denom string `json:"denom" yaml:"denom"` + Start time.Time `json:"start" yaml:"start"` + End time.Time `json:"end" yaml:"end"` + Reward sdk.Coin `json:"reward" yaml:"reward"` // per second reward payouts + ClaimEnd time.Time `json:"claim_end" yaml:"claim_end"` + ClaimTimeLock time.Duration `json:"claim_time_lock" yaml:"claim_time_lock"` // the amount of time rewards are timelocked once they are sent to users +} + +// String implements fmt.Stringer +func (rp RewardPeriod) String() string { + return fmt.Sprintf(`Reward Period: + Denom: %s, + Start: %s, + End: %s, + Reward: %s, + Claim End: %s, + Claim Time Lock: %s`, + rp.Denom, rp.Start, rp.End, rp.Reward, rp.ClaimEnd, rp.ClaimTimeLock) +} + +// NewRewardPeriod returns a new RewardPeriod +func NewRewardPeriod(denom string, start time.Time, end time.Time, reward sdk.Coin, claimEnd time.Time, claimTimeLock time.Duration) RewardPeriod { + return RewardPeriod{ + Denom: denom, + Start: start, + End: end, + Reward: reward, + ClaimEnd: claimEnd, + ClaimTimeLock: claimTimeLock, + } +} + +// RewardPeriods array of RewardPeriod +type RewardPeriods []RewardPeriod + +// ClaimPeriod stores the state of an ongoing claim period +type ClaimPeriod struct { + Denom string `json:"denom" yaml:"denom"` + ID uint64 `json:"id" yaml:"id"` + End time.Time `json:"end" yaml:"end"` + TimeLock time.Duration `json:"time_lock" yaml:"time_lock"` +} + +// NewClaimPeriod returns a new ClaimPeriod +func NewClaimPeriod(denom string, id uint64, end time.Time, timeLock time.Duration) ClaimPeriod { + return ClaimPeriod{ + Denom: denom, + ID: id, + End: end, + TimeLock: timeLock, + } +} + +// ClaimPeriods array of ClaimPeriod +type ClaimPeriods []ClaimPeriod + +// Claim stores the rewards that can be claimed by owner +type Claim struct { + Owner sdk.AccAddress `json:"owner" yaml:"owner"` + Reward sdk.Coin `json:"reward" yaml:"reward"` + Denom string `json:"denom" yaml:"denom"` + ClaimPeriodID uint64 `json:"claim_period_id" yaml:"claim_period_id"` +} + +// NewClaim returns a new Claim +func NewClaim(owner sdk.AccAddress, reward sdk.Coin, denom string, claimPeriodID uint64) Claim { + return Claim{ + Owner: owner, + Reward: reward, + Denom: denom, + ClaimPeriodID: claimPeriodID, + } +} + +// String implements fmt.Stringer +func (c Claim) String() string { + return fmt.Sprintf(`Claim: + Owner: %s, + Denom: %s, + Reward: %s, + Claim Period ID: %d,`, + c.Owner, c.Denom, c.Reward, c.ClaimPeriodID) +} + +// Claims array of Claim +type Claims []Claim