diff --git a/app/modules.go b/app/modules.go index 50282356..4b5da10b 100644 --- a/app/modules.go +++ b/app/modules.go @@ -154,7 +154,7 @@ func appModules( pools.NewAppModule(appCodec, app.PoolsKeeper), restaking.NewAppModule(appCodec, app.RestakingKeeper, app.AccountKeeper, app.BankKeeper, app.PoolsKeeper, app.OperatorsKeeper, app.ServicesKeeper), assets.NewAppModule(appCodec, app.AssetsKeeper), - rewards.NewAppModule(appCodec, app.RewardsKeeper), + rewards.NewAppModule(appCodec, app.RewardsKeeper, app.AccountKeeper, app.BankKeeper, app.PoolsKeeper, app.OperatorsKeeper, app.ServicesKeeper), liquidvesting.NewAppModule(appCodec, app.LiquidVestingKeeper), } } @@ -206,6 +206,7 @@ func simulationModules( services.NewAppModule(appCodec, app.ServicesKeeper, app.AccountKeeper, app.BankKeeper), operators.NewAppModule(appCodec, app.OperatorsKeeper, app.AccountKeeper, app.BankKeeper, app.StakingKeeper), restaking.NewAppModule(appCodec, app.RestakingKeeper, app.AccountKeeper, app.BankKeeper, app.PoolsKeeper, app.OperatorsKeeper, app.ServicesKeeper), + rewards.NewAppModule(appCodec, app.RewardsKeeper, app.AccountKeeper, app.BankKeeper, app.PoolsKeeper, app.OperatorsKeeper, app.ServicesKeeper), } } diff --git a/testutils/simtesting/utils.go b/testutils/simtesting/utils.go index f31c7bd9..8e876f2b 100644 --- a/testutils/simtesting/utils.go +++ b/testutils/simtesting/utils.go @@ -88,6 +88,17 @@ func RandomCoin(r *rand.Rand, denom string, maxAmount int) sdk.Coin { ) } +// RandomDecCoins returns a random list of DecCoins by randomly selecting +// a sub set of the provided denoms. +func RandomDecCoins(r *rand.Rand, denoms []string, maxAmount sdkmath.LegacyDec) sdk.DecCoins { + coins := sdk.NewDecCoins() + for _, denom := range RandomSubSlice(r, denoms) { + coins = coins.Add(sdk.NewDecCoinFromDec(denom, simtypes.RandomDecAmount(r, maxAmount))) + } + + return coins +} + // RandomSubSlice returns a random subset of the given slice func RandomSubSlice[T any](r *rand.Rand, items []T) []T { // Empty slice, we can't pick random elements @@ -117,3 +128,26 @@ func RandomSubSlice[T any](r *rand.Rand, items []T) []T { return elements } + +// RandomPositiveUint32 returns a random positive uint32 +func RandomPositiveUint32(r *rand.Rand) uint32 { + value := r.Uint32() + for value == 0 { + value = r.Uint32() + } + return value +} + +// RandomPositiveUint64 returns a random positive uint64 +func RandomPositiveUint64(r *rand.Rand) uint64 { + value := r.Uint64() + for value == 0 { + value = r.Uint64() + } + return value +} + +// RandomSliceElement returns a random element from the given slice +func RandomSliceElement[T any](r *rand.Rand, slice []T) T { + return slice[r.Intn(len(slice))] +} diff --git a/utils/slices.go b/utils/slices.go index f26a7d0d..822420ef 100644 --- a/utils/slices.go +++ b/utils/slices.go @@ -57,6 +57,21 @@ func RemoveDuplicates[T comparable](slice []T) []T { return result } +// RemoveDuplicatesFunc removes all duplicate elements from the slice based +// on the value returned by the provided function. +func RemoveDuplicatesFunc[T any, C comparable](slice []T, compareBy func(T) C) []T { + seen := make(map[C]bool) + result := make([]T, 0, len(slice)) + for _, v := range slice { + compareValue := compareBy(v) + if _, ok := seen[compareValue]; !ok { + seen[compareValue] = true + result = append(result, v) + } + } + return result +} + // Remove removes the first instance of value from the provided slice. func Remove[T comparable](slice []T, value T) (newSlice []T, removed bool) { index := -1 diff --git a/x/rewards/module.go b/x/rewards/module.go index 76c9107e..d136b4b3 100644 --- a/x/rewards/module.go +++ b/x/rewards/module.go @@ -5,19 +5,25 @@ import ( "encoding/json" "fmt" - "github.com/grpc-ecosystem/grpc-gateway/runtime" - "github.com/spf13/cobra" - "cosmossdk.io/core/appmodule" "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/codec" cdctypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/spf13/cobra" + operatorskeeper "github.com/milkyway-labs/milkyway/v3/x/operators/keeper" + poolskeeper "github.com/milkyway-labs/milkyway/v3/x/pools/keeper" "github.com/milkyway-labs/milkyway/v3/x/rewards/client/cli" "github.com/milkyway-labs/milkyway/v3/x/rewards/keeper" + "github.com/milkyway-labs/milkyway/v3/x/rewards/simulation" "github.com/milkyway-labs/milkyway/v3/x/rewards/types" + serviceskeeper "github.com/milkyway-labs/milkyway/v3/x/services/keeper" ) const ( @@ -25,8 +31,9 @@ const ( ) var ( - _ appmodule.AppModule = AppModule{} - _ module.AppModuleBasic = AppModuleBasic{} + _ appmodule.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModuleSimulation = AppModule{} ) // ---------------------------------------------------------------------------- @@ -93,12 +100,32 @@ type AppModule struct { // To ensure setting hooks properly, keeper must be a reference keeper *keeper.Keeper + + accountKeeper authkeeper.AccountKeeper + bankKeeper bankkeeper.Keeper + + poolsKeeper *poolskeeper.Keeper + operatorsKeeper *operatorskeeper.Keeper + servicesKeeper *serviceskeeper.Keeper } -func NewAppModule(cdc codec.Codec, keeper *keeper.Keeper) AppModule { +func NewAppModule( + cdc codec.Codec, + keeper *keeper.Keeper, + accountKeeper authkeeper.AccountKeeper, + bankKeeper bankkeeper.Keeper, + poolsKeeper *poolskeeper.Keeper, + operatorsKeeper *operatorskeeper.Keeper, + serviceKeeper *serviceskeeper.Keeper, +) AppModule { return AppModule{ - AppModuleBasic: NewAppModuleBasic(cdc), - keeper: keeper, + AppModuleBasic: NewAppModuleBasic(cdc), + keeper: keeper, + accountKeeper: accountKeeper, + bankKeeper: bankKeeper, + poolsKeeper: poolsKeeper, + operatorsKeeper: operatorsKeeper, + servicesKeeper: serviceKeeper, } } @@ -149,3 +176,35 @@ func (am AppModule) BeginBlock(ctx context.Context) error { func (am AppModule) IsOnePerModuleType() {} func (am AppModule) IsAppModule() {} + +// ---------------------------------------------------------------------------- +// AppModuleSimulation +// ---------------------------------------------------------------------------- + +// GenerateGenesisState creates a randomized GenState of the rewards module. +func (AppModule) GenerateGenesisState(simState *module.SimulationState) { + simulation.RandomizedGenState(simState) +} + +// ProposalMsgs returns msgs used for governance proposals for simulations. +func (am AppModule) ProposalMsgs(simState module.SimulationState) []simtypes.WeightedProposalMsg { + return simulation.ProposalMsgs(am.bankKeeper) +} + +// RegisterStoreDecoder registers a decoder for rewards module's types. +func (am AppModule) RegisterStoreDecoder(sdr simtypes.StoreDecoderRegistry) { + sdr[types.StoreKey] = simtypes.NewStoreDecoderFuncFromCollectionsSchema(am.keeper.Schema) +} + +// WeightedOperations returns the all the rewards module operations with their respective weights. +func (am AppModule) WeightedOperations(simState module.SimulationState) []simtypes.WeightedOperation { + return simulation.WeightedOperations( + simState.AppParams, + am.accountKeeper, + am.bankKeeper, + am.poolsKeeper, + am.operatorsKeeper, + am.servicesKeeper, + am.keeper, + ) +} diff --git a/x/rewards/simulation/genesis.go b/x/rewards/simulation/genesis.go new file mode 100644 index 00000000..c7b1678b --- /dev/null +++ b/x/rewards/simulation/genesis.go @@ -0,0 +1,47 @@ +package simulation + +import ( + "github.com/cosmos/cosmos-sdk/types/module" + + operatorssimulation "github.com/milkyway-labs/milkyway/v3/x/operators/simulation" + poolssimulation "github.com/milkyway-labs/milkyway/v3/x/pools/simulation" + "github.com/milkyway-labs/milkyway/v3/x/rewards/types" + servicessimulation "github.com/milkyway-labs/milkyway/v3/x/services/simulation" +) + +// RandomizedGenState generates a random GenesisState for the operators module +func RandomizedGenState(simState *module.SimulationState) { + servicesGenesis := servicessimulation.GetGenesisState(simState) + poolsGenesis := poolssimulation.GetGenesisState(simState) + operatorsGenesis := operatorssimulation.GetGenesisState(simState) + + rewardsPlans := RandomRewardsPlans( + simState.Rand, + poolsGenesis.Pools, + operatorsGenesis.Operators, + servicesGenesis.Services, + []string{simState.BondDenom}, + ) + nextRewardsPlan := uint64(1) + for _, plan := range rewardsPlans { + if plan.ID >= nextRewardsPlan { + nextRewardsPlan = plan.ID + 1 + } + } + + genesis := types.NewGenesisState( + RandomParams(simState.Rand, []string{simState.BondDenom}), + nextRewardsPlan, + rewardsPlans, + nil, + RandomDelegatorWithdrawInfos(simState.Rand, simState.Accounts), + // Empty delegation type records since we need to perform side effects on + // other modules to have valid delegations + types.NewDelegationTypeRecords(nil, nil, nil, nil), + types.NewDelegationTypeRecords(nil, nil, nil, nil), + types.NewDelegationTypeRecords(nil, nil, nil, nil), + RandomOperatorAccumulatedCommissionRecords(simState.Rand, operatorsGenesis.Operators, []string{simState.BondDenom}), + RandomPoolServiceTotalDelegatorShares(simState.Rand, poolsGenesis, servicesGenesis, []string{simState.BondDenom}), + ) + simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(genesis) +} diff --git a/x/rewards/simulation/msg_factory.go b/x/rewards/simulation/msg_factory.go new file mode 100644 index 00000000..3de93cc3 --- /dev/null +++ b/x/rewards/simulation/msg_factory.go @@ -0,0 +1,353 @@ +package simulation + +import ( + "errors" + "fmt" + "math/rand" + "time" + + "cosmossdk.io/collections" + "github.com/cosmos/cosmos-sdk/baseapp" + sdk "github.com/cosmos/cosmos-sdk/types" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/milkyway-labs/milkyway/v3/testutils/simtesting" + "github.com/milkyway-labs/milkyway/v3/utils" + operatorskeeper "github.com/milkyway-labs/milkyway/v3/x/operators/keeper" + operatorssimulation "github.com/milkyway-labs/milkyway/v3/x/operators/simulation" + operatorstypes "github.com/milkyway-labs/milkyway/v3/x/operators/types" + poolskeeper "github.com/milkyway-labs/milkyway/v3/x/pools/keeper" + poolssimulation "github.com/milkyway-labs/milkyway/v3/x/pools/simulation" + restakingtypes "github.com/milkyway-labs/milkyway/v3/x/restaking/types" + "github.com/milkyway-labs/milkyway/v3/x/rewards/keeper" + "github.com/milkyway-labs/milkyway/v3/x/rewards/types" + serviceskeeper "github.com/milkyway-labs/milkyway/v3/x/services/keeper" + servicessimulation "github.com/milkyway-labs/milkyway/v3/x/services/simulation" +) + +// Simulation operation weights constants +const ( + DefaultWeightMsgCreateRewardsPlan int = 80 + DefaultWeightMsgEditRewardsPlan int = 40 + DefaultWeightMsgSetWithdrawAddress int = 20 + DefaultWeightMsgWithdrawDelegatorReward int = 30 + DefaultWeightMsgWithdrawOperatorCommission int = 10 + + OperationWeightMsgCreateRewardsPlan = "op_weight_msg_create_rewards_plan" + OperationWeightMsgEditRewardsPlan = "op_weight_msg_edit_rewards_plan" + OperationWeightMsgSetWithdrawAddress = "op_weight_msg_set_withdraw_address" + OperationWeightMsgWithdrawDelegatorReward = "op_weight_msg_withdraw_delegator_reward" + OperationWeightMsgWithdrawOperatorCommission = "op_weight_msg_withdraw_operator_commission" +) + +func WeightedOperations( + appParams simtypes.AppParams, + ak authkeeper.AccountKeeper, + bk bankkeeper.Keeper, + pk *poolskeeper.Keeper, + ok *operatorskeeper.Keeper, + sk *serviceskeeper.Keeper, + k *keeper.Keeper, +) simulation.WeightedOperations { + var ( + weightMsgCreateRewardsPlan int + weightMsgEditRewardsPlan int + weightMsgSetWithdrawAddress int + weightMsgWithdrawDelegatorReward int + weightMsgWithdrawOperatorCommission int + ) + + // Generate the weights for the messages + appParams.GetOrGenerate(OperationWeightMsgCreateRewardsPlan, &weightMsgCreateRewardsPlan, nil, func(_ *rand.Rand) { + weightMsgCreateRewardsPlan = DefaultWeightMsgCreateRewardsPlan + }) + + appParams.GetOrGenerate(OperationWeightMsgEditRewardsPlan, &weightMsgEditRewardsPlan, nil, func(_ *rand.Rand) { + weightMsgEditRewardsPlan = DefaultWeightMsgEditRewardsPlan + }) + + appParams.GetOrGenerate(OperationWeightMsgSetWithdrawAddress, &weightMsgSetWithdrawAddress, nil, func(_ *rand.Rand) { + weightMsgSetWithdrawAddress = DefaultWeightMsgEditRewardsPlan + }) + + appParams.GetOrGenerate(OperationWeightMsgWithdrawDelegatorReward, &weightMsgWithdrawDelegatorReward, nil, func(_ *rand.Rand) { + weightMsgWithdrawDelegatorReward = DefaultWeightMsgWithdrawDelegatorReward + }) + + appParams.GetOrGenerate(OperationWeightMsgWithdrawOperatorCommission, &weightMsgWithdrawOperatorCommission, nil, func(_ *rand.Rand) { + weightMsgWithdrawOperatorCommission = DefaultWeightMsgWithdrawOperatorCommission + }) + + return simulation.WeightedOperations{ + simulation.NewWeightedOperation(weightMsgCreateRewardsPlan, SimulateMsgCreateRewardsPlan(ak, bk, pk, ok, sk, k)), + simulation.NewWeightedOperation(weightMsgEditRewardsPlan, SimulateMsgEditRewardsPlan(ak, bk, pk, ok, sk, k)), + simulation.NewWeightedOperation(weightMsgSetWithdrawAddress, SimulateMsgSetWithdrawAddress(ak, bk, k)), + simulation.NewWeightedOperation(weightMsgWithdrawDelegatorReward, SimulateMsgWithdrawDelegatorReward(ak, bk, k)), + simulation.NewWeightedOperation(weightMsgWithdrawOperatorCommission, SimulateMsgWithdrawOperatorCommission(ak, bk, ok, k)), + } +} + +// -------------------------------------------------------------------------------------------------------------------- + +func SimulateMsgCreateRewardsPlan( + ak authkeeper.AccountKeeper, + bk bankkeeper.Keeper, + pk *poolskeeper.Keeper, + ok *operatorskeeper.Keeper, + sk *serviceskeeper.Keeper, + k *keeper.Keeper, +) simtypes.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string, + ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) { + msg := &types.MsgCreateRewardsPlan{} + + // Get a random service + service, found := servicessimulation.GetRandomExistingService(r, ctx, sk, nil) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no service found"), nil, nil + } + + // Get the module parameters to get the fees required to create a rewards plan + rewardsParams, err := k.GetParams(ctx) + if err != nil { + panic(err) + } + + // Get a random account + adminAddress, err := sdk.AccAddressFromBech32(service.Admin) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "invalid admin address"), nil, nil + } + + // Ensure the sender has enough balance to create a rewards plan + senderSpendableBalance := bk.SpendableCoins(ctx, adminAddress) + if !senderSpendableBalance.IsAllGTE(rewardsParams.RewardsPlanCreationFee) { + return simtypes.NoOpMsg( + types.ModuleName, + sdk.MsgTypeURL(msg), + fmt.Sprintf("sender: %s don't have enough balance to create rewards plan, required: %s, available: %s", + service.Admin, + rewardsParams.RewardsPlanCreationFee.String(), + senderSpendableBalance.String(), + ), + ), nil, nil + } + + // Get a random pool that we will use to configure the pool distribution + pool, found := poolssimulation.GetRandomExistingPool(r, ctx, pk, nil) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no pool found"), nil, nil + } + + // Get a random operator that we will use to configure the operator distribution + operator, found := operatorssimulation.GetRandomExistingOperator(r, ctx, ok, func(o operatorstypes.Operator) bool { + return o.IsActive() + }) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no operator found"), nil, nil + } + + // Compute some random start/end time + rewardsStart := ctx.BlockTime().Add(time.Hour * time.Duration(r.Intn(10)+1)) + rewardsEnd := rewardsStart.Add(time.Hour * time.Duration(r.Intn(96)+1)) + + // Get a random rewards plan amount + amount, err := simtypes.RandomFees(r, ctx, senderSpendableBalance) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), err.Error()), nil, nil + } + + signer, found := simtesting.GetSimAccount(adminAddress, accs) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "admin account not found"), nil, nil + } + + msg = types.NewMsgCreateRewardsPlan( + service.ID, + simtypes.RandStringOfLength(r, 32), + amount, + rewardsStart, + rewardsEnd, + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_POOL, pool), + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_OPERATOR, operator), + RandomUsersDistribution(r), + rewardsParams.RewardsPlanCreationFee, + service.Admin, + ) + return simtesting.SendMsg(r, types.ModuleName, app, ak, bk, msg, ctx, signer) + } +} + +func SimulateMsgEditRewardsPlan( + ak authkeeper.AccountKeeper, + bk bankkeeper.Keeper, + pk *poolskeeper.Keeper, + ok *operatorskeeper.Keeper, + sk *serviceskeeper.Keeper, + k *keeper.Keeper, +) simtypes.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string, + ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) { + msg := &types.MsgEditRewardsPlan{} + + plan, found := GetRandomExistingRewardsPlan(r, ctx, k) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no rewards plan found"), nil, nil + } + + // Get a random pool that we will use to configure the pool distribution + pool, found := poolssimulation.GetRandomExistingPool(r, ctx, pk, nil) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no pool found"), nil, nil + } + + // Get a random operator that we will use to configure the operator distribution + operator, found := operatorssimulation.GetRandomExistingOperator(r, ctx, ok, func(o operatorstypes.Operator) bool { + return o.IsActive() + }) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no operator found"), nil, nil + } + + // Get the service admin + service, err := sk.GetService(ctx, plan.ServiceID) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "service not found"), nil, nil + } + panic(err) + } + + adminAddress, err := sdk.AccAddressFromBech32(service.Admin) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "invalid admin address"), nil, nil + } + + // Get a random rewards plan amount + senderSpendableBalance := bk.SpendableCoins(ctx, adminAddress) + amount, err := simtypes.RandomFees(r, ctx, senderSpendableBalance) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), err.Error()), nil, nil + } + + // Compute some random start/end time + rewardsStart := ctx.BlockTime().Add(time.Hour * time.Duration(r.Intn(10)+1)) + rewardsEnd := rewardsStart.Add(time.Hour * time.Duration(r.Intn(96)+1)) + + signer, found := simtesting.GetSimAccount(adminAddress, accs) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "admin account not found"), nil, nil + } + + msg = types.NewMsgEditRewardsPlan( + plan.ID, + simtypes.RandStringOfLength(r, 32), + amount, + rewardsStart, + rewardsEnd, + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_POOL, pool), + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_OPERATOR, operator), + RandomUsersDistribution(r), + service.Admin, + ) + return simtesting.SendMsg(r, types.ModuleName, app, ak, bk, msg, ctx, signer) + } +} + +func SimulateMsgSetWithdrawAddress(ak authkeeper.AccountKeeper, bk bankkeeper.Keeper, k *keeper.Keeper) simtypes.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string, + ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) { + msg := &types.MsgSetWithdrawAddress{} + + if len(accs) == 0 { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no accounts"), nil, nil + } + + sender, _ := simtypes.RandomAcc(r, accs) + msg = types.NewMsgSetWithdrawAddress(sender.Address.String(), sender.Address.String()) + return simtesting.SendMsg(r, types.ModuleName, app, ak, bk, msg, ctx, sender) + } +} + +func SimulateMsgWithdrawDelegatorReward(ak authkeeper.AccountKeeper, bk bankkeeper.Keeper, k *keeper.Keeper) simtypes.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string, + ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) { + msg := &types.MsgWithdrawDelegatorReward{} + + if len(accs) == 0 { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no accounts"), nil, nil + } + sender, _ := simtypes.RandomAcc(r, accs) + + // Get the user's pending rewards + queryServer := keeper.NewQueryServer(k) + res, err := queryServer.DelegatorTotalRewards(ctx, &types.QueryDelegatorTotalRewardsRequest{ + DelegatorAddress: sender.Address.String(), + }) + if err != nil { + panic(err) + } + + // Get delegation reward so that we can withdraw the rewards + delegatorRewards, found := utils.Find(res.Rewards, func(d types.DelegationDelegatorReward) bool { + return !d.Reward.IsEmpty() + }) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no rewards"), nil, nil + } + + msg = types.NewMsgWithdrawDelegatorReward( + delegatorRewards.DelegationType, + delegatorRewards.DelegationTargetID, + sender.Address.String(), + ) + return simtesting.SendMsg(r, types.ModuleName, app, ak, bk, msg, ctx, sender) + } +} + +func SimulateMsgWithdrawOperatorCommission( + ak authkeeper.AccountKeeper, + bk bankkeeper.Keeper, + ok *operatorskeeper.Keeper, + k *keeper.Keeper, +) simtypes.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string, + ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) { + msg := &types.MsgWithdrawOperatorCommission{} + + operator, found := operatorssimulation.GetRandomExistingOperator(r, ctx, ok, nil) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "operator not found"), nil, nil + } + + adminAddress, err := sdk.AccAddressFromBech32(operator.Admin) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "invalid admin address"), nil, nil + } + + signer, found := simtesting.GetSimAccount(adminAddress, accs) + if !found { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "admin account not found"), nil, nil + } + + commission, err := k.GetOperatorAccumulatedCommission(ctx, operator.ID) + if err != nil { + panic(err) + } + + if commission.Commissions.IsEmpty() { + return simtypes.NoOpMsg(types.ModuleName, sdk.MsgTypeURL(msg), "no commissions to withdraw"), nil, nil + } + + msg = types.NewMsgWithdrawOperatorCommission(operator.ID, operator.Admin) + return simtesting.SendMsg(r, types.ModuleName, app, ak, bk, msg, ctx, signer) + } +} diff --git a/x/rewards/simulation/proposals.go b/x/rewards/simulation/proposals.go new file mode 100644 index 00000000..7682e03c --- /dev/null +++ b/x/rewards/simulation/proposals.go @@ -0,0 +1,52 @@ +package simulation + +import ( + "math/rand" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/address" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/milkyway-labs/milkyway/v3/utils" + "github.com/milkyway-labs/milkyway/v3/x/rewards/types" +) + +const ( + DefaultWeightMsgUpdateParams = 30 + + OperationWeightMsgUpdateParams = "op_weight_msg_update_params" +) + +func ProposalMsgs(bankKeeper bankkeeper.Keeper) []simtypes.WeightedProposalMsg { + return []simtypes.WeightedProposalMsg{ + simulation.NewWeightedProposalMsg( + OperationWeightMsgUpdateParams, + DefaultWeightMsgUpdateParams, + SimulateMsgUpdateParams(bankKeeper), + ), + } +} + +// SimulateMsgUpdateParams returns a random MsgUpdateParams +func SimulateMsgUpdateParams(bankKeeper bankkeeper.Keeper) func(r *rand.Rand, _ sdk.Context, _ []simtypes.Account) sdk.Msg { + return func(r *rand.Rand, ctx sdk.Context, _ []simtypes.Account) sdk.Msg { + // Use the default gov module account address as authority + var authority sdk.AccAddress = address.Module(govtypes.ModuleName) + + // Get all the denoms + metadata := bankKeeper.GetAllDenomMetaData(ctx) + denoms := utils.Map(metadata, func(md banktypes.Metadata) string { + return md.Base + }) + + // Generate the new random params + params := RandomParams(r, denoms) + + // Return the message + return types.NewMsgUpdateParams(params, authority.String()) + } +} diff --git a/x/rewards/simulation/utils.go b/x/rewards/simulation/utils.go new file mode 100644 index 00000000..7d28beb2 --- /dev/null +++ b/x/rewards/simulation/utils.go @@ -0,0 +1,314 @@ +package simulation + +import ( + "math/rand" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + + "github.com/milkyway-labs/milkyway/v3/testutils/simtesting" + "github.com/milkyway-labs/milkyway/v3/utils" + operatorstypes "github.com/milkyway-labs/milkyway/v3/x/operators/types" + poolstypes "github.com/milkyway-labs/milkyway/v3/x/pools/types" + restakingtypes "github.com/milkyway-labs/milkyway/v3/x/restaking/types" + "github.com/milkyway-labs/milkyway/v3/x/rewards/keeper" + "github.com/milkyway-labs/milkyway/v3/x/rewards/types" + servicestypes "github.com/milkyway-labs/milkyway/v3/x/services/types" +) + +func RandomParams(r *rand.Rand, rewardsPlanCreationFeeDenoms []string) types.Params { + rewardsPlanCreationFees := sdk.NewCoins() + for _, denom := range rewardsPlanCreationFeeDenoms { + rewardsPlanCreationFees = rewardsPlanCreationFees.Add( + sdk.NewInt64Coin(denom, r.Int63())) + } + + return types.NewParams(rewardsPlanCreationFees) +} + +func RandomDistributionType(r *rand.Rand) types.DistributionType { + switch r.Intn(3) { + case 1: + weightsCount := r.Intn(5) + 1 + + var distributionWeights []types.DistributionWeight + for i := 0; i < weightsCount; i++ { + distributionWeights = append(distributionWeights, types.DistributionWeight{ + Weight: r.Uint32(), + DelegationTargetID: r.Uint32(), + }) + } + return &types.DistributionTypeWeighted{ + Weights: distributionWeights, + } + case 2: + return &types.DistributionTypeEgalitarian{} + default: + return &types.DistributionTypeBasic{} + } +} + +func RandomDistribution( + r *rand.Rand, + delegationType restakingtypes.DelegationType, + target restakingtypes.DelegationTarget, +) types.Distribution { + return types.NewDistribution( + delegationType, + target.GetID(), + RandomDistributionType(r), + ) +} + +func RandomUsersDistributionType(_ *rand.Rand) types.UsersDistributionType { + return &types.UsersDistributionTypeBasic{} +} + +func RandomUsersDistribution(r *rand.Rand) types.UsersDistribution { + return types.NewUsersDistribution(r.Uint32(), RandomUsersDistributionType(r)) +} + +func RandomRewardsPlan( + r *rand.Rand, + serviceID uint32, + pools []poolstypes.Pool, + operators []operatorstypes.Operator, + amtPerDeyDenoms []string, +) types.RewardsPlan { + randomAmountPerDays := sdk.NewCoins() + for _, denom := range amtPerDeyDenoms { + randomAmountPerDays = randomAmountPerDays.Add( + sdk.NewInt64Coin(denom, r.Int63())) + } + + randomPool := simtesting.RandomSliceElement(r, pools) + randomOperator := simtesting.RandomSliceElement(r, operators) + + return types.NewRewardsPlan( + r.Uint64(), + simtypes.RandStringOfLength(r, 32), + serviceID, + randomAmountPerDays, + simtypes.RandTimestamp(r), + simtypes.RandTimestamp(r), + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_POOL, randomPool), + RandomDistribution(r, restakingtypes.DELEGATION_TYPE_OPERATOR, randomOperator), + RandomUsersDistribution(r), + ) +} + +func RandomRewardsPlans( + r *rand.Rand, + pools []poolstypes.Pool, + operators []operatorstypes.Operator, + services []servicestypes.Service, + allowedDenoms []string, +) []types.RewardsPlan { + // We can't create a rewards plan if we don't have + // services, pools or operators + if len(services) == 0 || len(pools) == 0 || len(operators) == 0 { + return nil + } + + // Get a random numer of rewards plans to create + rewardsPlanCount := r.Intn(30) + // Generate the rewards plans + var rewardsPlans []types.RewardsPlan + for id := 0; id < rewardsPlanCount; id++ { + serviceIndex := r.Intn(len(services)) + rewardsPlans = append(rewardsPlans, RandomRewardsPlan( + r, + services[serviceIndex].ID, + pools, + operators, + allowedDenoms, + )) + } + + return rewardsPlans +} + +func GetRandomExistingRewardsPlan(r *rand.Rand, ctx sdk.Context, k *keeper.Keeper) (types.RewardsPlan, bool) { + var plans []types.RewardsPlan + k.RewardsPlans.Walk(ctx, nil, func(key uint64, p types.RewardsPlan) (bool, error) { + plans = append(plans, p) + return false, nil + }) + + if len(plans) == 0 { + return types.RewardsPlan{}, false + } + + return plans[r.Intn(len(plans))], true +} + +func RandomDelegatorWithdrawInfos(r *rand.Rand, accs []simtypes.Account) []types.DelegatorWithdrawInfo { + count := r.Intn(len(accs)) + + var infos []types.DelegatorWithdrawInfo + for i := 0; i < count; i++ { + randomAccount, _ := simtypes.RandomAcc(r, accs) + infos = append(infos, types.DelegatorWithdrawInfo{ + DelegatorAddress: randomAccount.Address.String(), + WithdrawAddress: randomAccount.Address.String(), + }) + } + + return utils.RemoveDuplicatesFunc(infos, func(i types.DelegatorWithdrawInfo) string { + return i.DelegatorAddress + }) +} + +func RandomDecPools(r *rand.Rand, availableDenoms []string) types.DecPools { + pools := types.NewDecPools() + + // Pick a random subset of denoms + denoms := simtesting.RandomSubSlice(r, availableDenoms) + if len(denoms) == 0 { + return pools + } + + for _, denom := range denoms { + // Generate a random amount + amount := simtypes.RandomAmount(r, math.NewIntFromUint64(r.Uint64())) + // Ignore if zero + if amount.IsZero() { + continue + } + + // Create a DecPool with the random amount + pool := types.NewDecPool(denom, sdk.NewDecCoins( + sdk.NewDecCoin(denom, amount), + )) + pools = pools.Add(pool) + } + + return pools +} + +func RandomServicePools( + r *rand.Rand, + servicesGenesis servicestypes.GenesisState, + availableDenoms []string, +) types.ServicePools { + var servicePools types.ServicePools + + services := simtesting.RandomSubSlice(r, servicesGenesis.Services) + for _, service := range services { + servicePools = append(servicePools, types.ServicePool{ + ServiceID: service.ID, + DecPools: RandomDecPools(r, availableDenoms), + }) + } + + return servicePools +} + +func RandomCurrentRewardsRecords( + r *rand.Rand, + servicesGenesis servicestypes.GenesisState, + availableDenoms []string, +) []types.CurrentRewardsRecord { + var currentRewardsRecords []types.CurrentRewardsRecord + + count := r.Intn(10) + for i := 0; i < count; i++ { + currentRewards := types.CurrentRewards{ + Rewards: RandomServicePools(r, servicesGenesis, availableDenoms), + Period: r.Uint64(), + } + // Ignore CurrentRewards if empty + if len(currentRewards.Rewards) == 0 { + continue + } + + currentRewardsRecords = append(currentRewardsRecords, types.CurrentRewardsRecord{ + DelegationTargetID: simtesting.RandomPositiveUint32(r), + Rewards: currentRewards, + }) + } + + return currentRewardsRecords +} + +func RandomDelegatorStartingInfoRecords( + r *rand.Rand, + availableDenoms []string, +) []types.DelegatorStartingInfoRecord { + var delegatorStartingInfoRecords []types.DelegatorStartingInfoRecord + + accounts := simtypes.RandomAccounts(r, r.Intn(10)) + for _, account := range accounts { + record := types.DelegatorStartingInfoRecord{ + DelegatorAddress: account.Address.String(), + DelegationTargetID: simtesting.RandomPositiveUint32(r), + StartingInfo: types.DelegatorStartingInfo{ + PreviousPeriod: simtesting.RandomPositiveUint64(r), + Stakes: simtesting.RandomDecCoins(r, availableDenoms, math.LegacyNewDec(r.Int63())), + Height: simtesting.RandomPositiveUint64(r), + }, + } + + delegatorStartingInfoRecords = append(delegatorStartingInfoRecords, record) + } + + return delegatorStartingInfoRecords +} + +func RandomOperatorAccumulatedCommissionRecords( + r *rand.Rand, + operators []operatorstypes.Operator, + availableDenoms []string, +) []types.OperatorAccumulatedCommissionRecord { + var records []types.OperatorAccumulatedCommissionRecord + if len(operators) == 0 { + return records + } + + count := r.Intn(10) + for i := 0; i < count; i++ { + randomDenoms := simtesting.RandomSubSlice(r, availableDenoms) + if len(randomDenoms) == 0 { + continue + } + + randomOperator := simtesting.RandomSliceElement(r, operators) + records = append(records, types.OperatorAccumulatedCommissionRecord{ + OperatorID: randomOperator.ID, + Accumulated: types.AccumulatedCommission{ + Commissions: RandomDecPools(r, availableDenoms), + }, + }) + } + + return records +} + +func RandomPoolServiceTotalDelegatorShares( + r *rand.Rand, + poolsGenesis poolstypes.GenesisState, + servicesGenesis servicestypes.GenesisState, + availableDenoms []string, +) []types.PoolServiceTotalDelegatorShares { + var records []types.PoolServiceTotalDelegatorShares + + services := simtesting.RandomSubSlice(r, servicesGenesis.Services) + pools := simtesting.RandomSubSlice(r, poolsGenesis.Pools) + for _, service := range services { + for _, pool := range pools { + randomDenom := availableDenoms[r.Intn(len(availableDenoms))] + decCoin := sdk.NewDecCoinFromDec(randomDenom, simtypes.RandomDecAmount( + r, math.LegacyNewDecFromInt(math.NewIntFromUint64(simtesting.RandomPositiveUint64(r))), + )) + + records = append(records, types.PoolServiceTotalDelegatorShares{ + PoolID: pool.ID, + ServiceID: service.ID, + Shares: sdk.NewDecCoins(decCoin), + }) + } + } + + return records +} diff --git a/x/rewards/types/dec_pool.go b/x/rewards/types/dec_pool.go index 556f62de..5169a796 100644 --- a/x/rewards/types/dec_pool.go +++ b/x/rewards/types/dec_pool.go @@ -22,6 +22,12 @@ func NewDecPoolsFromPools(pools Pools) DecPools { return decPools.Sort() } +// NewDecPools creates a new DecPools instance from the +// given DecPool +func NewDecPools(pools ...DecPool) DecPools { + return removeZeroDecPools(pools).Sort() +} + // Sum returns sum of pool tokens func (pools DecPools) Sum() (coins sdk.DecCoins) { for _, p := range pools { diff --git a/x/rewards/types/messages.go b/x/rewards/types/messages.go index 31797964..10ff1109 100644 --- a/x/rewards/types/messages.go +++ b/x/rewards/types/messages.go @@ -342,3 +342,13 @@ func (m *MsgWithdrawOperatorCommission) GetSigners() []sdk.AccAddress { return []sdk.AccAddress{addr} } + +// -------------------------------------------------------------------------------------------------------------------- + +// NewMsgUpdateParams creates a new MsgUpdateParams instance +func NewMsgUpdateParams(params Params, authority string) *MsgUpdateParams { + return &MsgUpdateParams{ + Authority: authority, + Params: params, + } +} diff --git a/x/rewards/types/models.go b/x/rewards/types/models.go index cfb43cfd..5e57801e 100644 --- a/x/rewards/types/models.go +++ b/x/rewards/types/models.go @@ -416,3 +416,12 @@ func (shares PoolServiceTotalDelegatorShares) Validate() error { } return nil } + +// -------------------------------------------------------------------------------------------------------------------- + +// NewOutstandingRewards creates a new OutstandingRewards instance. +func NewOutstandingRewards(rewards DecPools) OutstandingRewards { + return OutstandingRewards{ + Rewards: rewards, + } +}