From 23a5c7b969d1379a06d2b52dde4bd38bf636f07c Mon Sep 17 00:00:00 2001 From: Ruaridh Date: Mon, 4 May 2020 22:17:20 +0100 Subject: [PATCH] Add committee simulations (#431) * first pass at genesis and msgs * add proposal generation * add permission generation * add decoder * add invariants * add committee change proposal generator * improve committee change proposal generation * fix error formatting * update sims to v0.38 * Update x/committee/keeper/invariants.go Co-Authored-By: Denali Marsh * Update x/committee/keeper/invariants.go Co-Authored-By: Denali Marsh * tidy up comments * tidy up random helpers * add committee to ImportExport test * add member check to vote invariant * fix comment wording Co-authored-by: Kevin Davis Co-authored-by: Denali Marsh Co-authored-by: Kevin Davis --- app/params/params.go | 11 +- app/sim_test.go | 10 +- x/committee/alias.go | 4 + x/committee/keeper/invariants.go | 145 ++++++++++++++++++ x/committee/module.go | 16 +- x/committee/simulation/common.go | 71 +++++++++ x/committee/simulation/decoder.go | 34 ++++- x/committee/simulation/decoder_test.go | 77 ++++++++++ x/committee/simulation/genesis.go | 113 +++++++++++++- x/committee/simulation/operations.go | 199 +++++++++++++++++++++++++ x/committee/simulation/params.go | 14 -- x/committee/simulation/proposals.go | 173 +++++++++++++++++++++ x/committee/types/committee.go | 13 +- x/committee/types/genesis.go | 2 +- 14 files changed, 836 insertions(+), 46 deletions(-) create mode 100644 x/committee/keeper/invariants.go create mode 100644 x/committee/simulation/common.go create mode 100644 x/committee/simulation/decoder_test.go create mode 100644 x/committee/simulation/operations.go delete mode 100644 x/committee/simulation/params.go create mode 100644 x/committee/simulation/proposals.go diff --git a/app/params/params.go b/app/params/params.go index 64cf7471..7853bc39 100644 --- a/app/params/params.go +++ b/app/params/params.go @@ -8,9 +8,10 @@ const ( // Default simulation operation weights for messages and gov proposals const ( - DefaultWeightMsgPlaceBid int = 75 - DefaultWeightMsgCreateAtomicSwap int = 50 - DefaultWeightMsgUpdatePrices int = 50 - DefaultWeightMsgCdp int = 100 - DefaultWeightMsgClaimReward int = 50 + DefaultWeightMsgPlaceBid int = 75 + DefaultWeightMsgCreateAtomicSwap int = 50 + DefaultWeightMsgUpdatePrices int = 50 + DefaultWeightMsgCdp int = 100 + DefaultWeightMsgClaimReward int = 50 + OpWeightSubmitCommitteeChangeProposal int = 50 ) diff --git a/app/sim_test.go b/app/sim_test.go index 18d6e4ec..6acf2f61 100644 --- a/app/sim_test.go +++ b/app/sim_test.go @@ -8,6 +8,10 @@ import ( "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/libs/log" + dbm "github.com/tendermint/tm-db" + "github.com/cosmos/cosmos-sdk/baseapp" "github.com/cosmos/cosmos-sdk/simapp" "github.com/cosmos/cosmos-sdk/simapp/helpers" @@ -23,13 +27,10 @@ import ( "github.com/cosmos/cosmos-sdk/x/staking" "github.com/cosmos/cosmos-sdk/x/supply" - abci "github.com/tendermint/tendermint/abci/types" - "github.com/tendermint/tendermint/libs/log" - dbm "github.com/tendermint/tm-db" - "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/committee" "github.com/kava-labs/kava/x/incentive" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" @@ -178,6 +179,7 @@ func TestAppImportExport(t *testing.T) { {app.keys[kavadist.StoreKey], newApp.keys[kavadist.StoreKey], [][]byte{}}, {app.keys[pricefeed.StoreKey], newApp.keys[pricefeed.StoreKey], [][]byte{}}, {app.keys[validatorvesting.StoreKey], newApp.keys[validatorvesting.StoreKey], [][]byte{}}, + {app.keys[committee.StoreKey], newApp.keys[committee.StoreKey], [][]byte{}}, } for _, skp := range storeKeysPrefixes { diff --git a/x/committee/alias.go b/x/committee/alias.go index 5bc4c1ea..c326312b 100644 --- a/x/committee/alias.go +++ b/x/committee/alias.go @@ -43,6 +43,10 @@ var ( // function aliases NewKeeper = keeper.NewKeeper NewQuerier = keeper.NewQuerier + RegisterInvariants = keeper.RegisterInvariants + ValidCommitteesInvariant = keeper.ValidCommitteesInvariant + ValidProposalsInvariant = keeper.ValidProposalsInvariant + ValidVotesInvariant = keeper.ValidVotesInvariant DefaultGenesisState = types.DefaultGenesisState GetKeyFromID = types.GetKeyFromID GetVoteKey = types.GetVoteKey diff --git a/x/committee/keeper/invariants.go b/x/committee/keeper/invariants.go new file mode 100644 index 00000000..d5202ae2 --- /dev/null +++ b/x/committee/keeper/invariants.go @@ -0,0 +1,145 @@ +package keeper + +import ( + "fmt" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/committee/types" +) + +// RegisterInvariants registers all committee invariants +func RegisterInvariants(ir sdk.InvariantRegistry, k Keeper) { + + ir.RegisterRoute(types.ModuleName, "valid-committees", + ValidCommitteesInvariant(k)) + ir.RegisterRoute(types.ModuleName, "valid-proposals", + ValidProposalsInvariant(k)) + ir.RegisterRoute(types.ModuleName, "valid-votes", + ValidVotesInvariant(k)) +} + +// ValidCommitteesInvariant verifies that all committees in the store are independently valid +func ValidCommitteesInvariant(k Keeper) sdk.Invariant { + return func(ctx sdk.Context) (string, bool) { + + var validationErr error + var invalidCommittee types.Committee + k.IterateCommittees(ctx, func(com types.Committee) bool { + + if err := com.Validate(); err != nil { + validationErr = err + invalidCommittee = com + return true + } + return false + }) + + broken := validationErr != nil + invariantMessage := sdk.FormatInvariant( + types.ModuleName, + "valid committees", + fmt.Sprintf( + "\tfound invalid committee, reason: %s\n"+ + "\tcommittee:\n\t%+v\n", + validationErr, invalidCommittee), + ) + return invariantMessage, broken + } +} + +// ValidProposalsInvariant verifies that all proposals in the store are valid +func ValidProposalsInvariant(k Keeper) sdk.Invariant { + return func(ctx sdk.Context) (string, bool) { + + var validationErr error + var invalidProposal types.Proposal + k.IterateProposals(ctx, func(proposal types.Proposal) bool { + invalidProposal = proposal + + if err := proposal.PubProposal.ValidateBasic(); err != nil { + validationErr = err + return true + } + + currentTime := ctx.BlockTime() + if !currentTime.Equal(time.Time{}) { // this avoids a simulator bug where app.InitGenesis is called with blockTime=0 instead of the correct time + if proposal.Deadline.Before(currentTime) { + validationErr = fmt.Errorf("deadline after current block time %s", currentTime) + return true + } + } + + com, found := k.GetCommittee(ctx, proposal.CommitteeID) + if !found { + validationErr = fmt.Errorf("proposal has no committee %d", proposal.CommitteeID) + return true + } + + if !com.HasPermissionsFor(proposal.PubProposal) { + validationErr = fmt.Errorf("proposal not permitted for committee %+v", com) + return true + } + + return false + }) + + broken := validationErr != nil + invariantMessage := sdk.FormatInvariant( + types.ModuleName, + "valid proposals", + fmt.Sprintf( + "\tfound invalid proposal, reason: %s\n"+ + "\tproposal:\n\t%s\n", + validationErr, invalidProposal), + ) + return invariantMessage, broken + } +} + +// ValidVotesInvariant verifies that all votes in the store are valid +func ValidVotesInvariant(k Keeper) sdk.Invariant { + return func(ctx sdk.Context) (string, bool) { + + var validationErr error + var invalidVote types.Vote + k.IterateVotes(ctx, func(vote types.Vote) bool { + invalidVote = vote + + if vote.Voter.Empty() { + validationErr = fmt.Errorf("empty voter address") + return true + } + + proposal, found := k.GetProposal(ctx, vote.ProposalID) + if !found { + validationErr = fmt.Errorf("vote has no proposal %d", vote.ProposalID) + return true + } + + com, found := k.GetCommittee(ctx, proposal.CommitteeID) + if !found { + validationErr = fmt.Errorf("vote's proposal has no committee %d", proposal.CommitteeID) + return true + } + if !com.HasMember(vote.Voter) { + validationErr = fmt.Errorf("voter is not a member of committee %+v", com) + return true + } + + return false + }) + + broken := validationErr != nil + invariantMessage := sdk.FormatInvariant( + types.ModuleName, + "valid votes", + fmt.Sprintf( + "\tfound invalid vote, reason: %s\n"+ + "\tvote:\n\t%+v\n", + validationErr, invalidVote), + ) + return invariantMessage, broken + } +} diff --git a/x/committee/module.go b/x/committee/module.go index 857d8a1e..cdb55138 100644 --- a/x/committee/module.go +++ b/x/committee/module.go @@ -95,7 +95,9 @@ func (AppModule) Name() string { } // RegisterInvariants register module invariants -func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} +func (am AppModule) RegisterInvariants(ir sdk.InvariantRegistry) { + RegisterInvariants(ir, am.keeper) +} // Route module message route name func (AppModule) Route() string { @@ -149,12 +151,12 @@ func (AppModuleBasic) GenerateGenesisState(simState *module.SimulationState) { simulation.RandomizedGenState(simState) } -// TODO -func (AppModuleBasic) ProposalContents(_ module.SimulationState) []sim.WeightedProposalContent { - return nil +// ProposalContents returns functions that generate gov proposals for the module +func (am AppModule) ProposalContents(simState module.SimulationState) []sim.WeightedProposalContent { + return simulation.ProposalContents(am.keeper, simState.ParamChanges) } -// RandomizedParams returns functions that generate params for the module. +// RandomizedParams returns functions that generate params for the module func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { return nil } @@ -164,7 +166,7 @@ func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { sdr[StoreKey] = simulation.DecodeStore } -// WeightedOperations returns the all the auction module operations with their respective weights. +// WeightedOperations returns the module operations for use in simulations func (am AppModule) WeightedOperations(simState module.SimulationState) []sim.WeightedOperation { - return nil // TODO simulation.WeightedOperations(simState.AppParams, simState.Cdc, am.accountKeeper, am.keeper) + return simulation.WeightedOperations(simState.AppParams, simState.Cdc, am.accountKeeper, am.keeper, simState.Contents) } diff --git a/x/committee/simulation/common.go b/x/committee/simulation/common.go new file mode 100644 index 00000000..80383b8f --- /dev/null +++ b/x/committee/simulation/common.go @@ -0,0 +1,71 @@ +package simulation + +import ( + "fmt" + "math/big" + "math/rand" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/simulation" +) + +func RandomAddresses(r *rand.Rand, accs []simulation.Account) []sdk.AccAddress { + r.Shuffle(len(accs), func(i, j int) { + accs[i], accs[j] = accs[j], accs[i] + }) + + var addresses []sdk.AccAddress + numAddresses := r.Intn(len(accs) + 1) + for i := 0; i < numAddresses; i++ { + addresses = append(addresses, accs[i].Address) + } + return addresses +} + +func RandomPositiveDuration(r *rand.Rand, inclusiveMin, exclusiveMax time.Duration) (time.Duration, error) { + min := int64(inclusiveMin) + max := int64(exclusiveMax) + if min < 0 || max < 0 { + return 0, fmt.Errorf("min and max must be positive") + } + if min >= max { + return 0, fmt.Errorf("max must be < min") + } + randPositiveInt64 := r.Int63n(max-min) + min + return time.Duration(randPositiveInt64), nil +} + +func RandomTime(r *rand.Rand, inclusiveMin, exclusiveMax time.Time) (time.Time, error) { + if exclusiveMax.Before(inclusiveMin) { + return time.Time{}, fmt.Errorf("max must be > min") + } + period := exclusiveMax.Sub(inclusiveMin) + subPeriod, err := RandomPositiveDuration(r, 0, period) + if err != nil { + return time.Time{}, err + } + return inclusiveMin.Add(subPeriod), nil +} + +// RandInt randomly generates an sdk.Int in the range [inclusiveMin, inclusiveMax]. It works for negative and positive integers. +func RandIntInclusive(r *rand.Rand, inclusiveMin, inclusiveMax sdk.Int) (sdk.Int, error) { + if inclusiveMin.GT(inclusiveMax) { + return sdk.Int{}, fmt.Errorf("min larger than max") + } + return RandInt(r, inclusiveMin, inclusiveMax.Add(sdk.OneInt())) +} + +// RandInt randomly generates an sdk.Int in the range [inclusiveMin, exclusiveMax). It works for negative and positive integers. +func RandInt(r *rand.Rand, inclusiveMin, exclusiveMax sdk.Int) (sdk.Int, error) { + // validate input + if inclusiveMin.GTE(exclusiveMax) { + return sdk.Int{}, fmt.Errorf("min larger or equal to max") + } + // shift the range to start at 0 + shiftedRange := exclusiveMax.Sub(inclusiveMin) // should always be positive given the check above + // randomly pick from the shifted range + shiftedRandInt := sdk.NewIntFromBigInt(new(big.Int).Rand(r, shiftedRange.BigInt())) + // shift back to the original range + return shiftedRandInt.Add(inclusiveMin), nil +} diff --git a/x/committee/simulation/decoder.go b/x/committee/simulation/decoder.go index 954c1f3a..8b2946a8 100644 --- a/x/committee/simulation/decoder.go +++ b/x/committee/simulation/decoder.go @@ -1,13 +1,43 @@ package simulation import ( + "bytes" + "fmt" + "github.com/cosmos/cosmos-sdk/codec" "github.com/tendermint/tendermint/libs/kv" + + "github.com/kava-labs/kava/x/committee/types" ) // DecodeStore unmarshals the KVPair's Value to the corresponding module type func DecodeStore(cdc *codec.Codec, kvA, kvB kv.Pair) string { - // TODO implement this - return "" + switch { + case bytes.Equal(kvA.Key[:1], types.CommitteeKeyPrefix): + var committeeA, committeeB types.Committee + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &committeeA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &committeeB) + return fmt.Sprintf("%v\n%v", committeeA, committeeB) + + case bytes.Equal(kvA.Key[:1], types.ProposalKeyPrefix): + var proposalA, proposalB types.Proposal + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &proposalA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &proposalB) + return fmt.Sprintf("%v\n%v", proposalA, proposalB) + + case bytes.Equal(kvA.Key[:1], types.VoteKeyPrefix): + var voteA, voteB types.Vote + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &voteA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &voteB) + return fmt.Sprintf("%v\n%v", voteA, voteB) + + case bytes.Equal(kvA.Key[:1], types.NextProposalIDKey): + proposalIDA := types.Uint64FromBytes(kvA.Value) + proposalIDB := types.Uint64FromBytes(kvB.Value) + return fmt.Sprintf("%d\n%d", proposalIDA, proposalIDB) + + default: + panic(fmt.Sprintf("invalid %s key prefix %X", types.ModuleName, kvA.Key[:1])) + } } diff --git a/x/committee/simulation/decoder_test.go b/x/committee/simulation/decoder_test.go new file mode 100644 index 00000000..3e361ac7 --- /dev/null +++ b/x/committee/simulation/decoder_test.go @@ -0,0 +1,77 @@ +package simulation + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tendermint/tendermint/libs/kv" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + + "github.com/kava-labs/kava/x/committee/types" +) + +func makeTestCodec() (cdc *codec.Codec) { + cdc = codec.New() + sdk.RegisterCodec(cdc) + govtypes.RegisterCodec(cdc) + types.RegisterCodec(cdc) + return cdc +} + +func TestDecodeStore(t *testing.T) { + cdc := makeTestCodec() + + committee := types.NewCommittee( + 12, + "This committee is for testing.", + nil, + []types.Permission{types.TextPermission{}}, + sdk.MustNewDecFromStr("0.667"), + time.Hour*24*7, + ) + proposal := types.Proposal{ + ID: 34, + CommitteeID: 12, + Deadline: time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC), + PubProposal: govtypes.NewTextProposal("A Title", "A description of this proposal."), + } + vote := types.Vote{ + ProposalID: 9, + Voter: nil, + } + + kvPairs := kv.Pairs{ + kv.Pair{Key: types.CommitteeKeyPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(&committee)}, + kv.Pair{Key: types.ProposalKeyPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(&proposal)}, + kv.Pair{Key: types.VoteKeyPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(&vote)}, + kv.Pair{Key: types.NextProposalIDKey, Value: sdk.Uint64ToBigEndian(10)}, + kv.Pair{Key: []byte{0x99}, Value: []byte{0x99}}, + } + + tests := []struct { + name string + expectedLog string + }{ + {"Committee", fmt.Sprintf("%v\n%v", committee, committee)}, + {"Proposal", fmt.Sprintf("%v\n%v", proposal, proposal)}, + {"Vote", fmt.Sprintf("%v\n%v", vote, vote)}, + {"NextProposalID", "10\n10"}, + {"other", ""}, + } + for i, tt := range tests { + i, tt := i, tt + t.Run(tt.name, func(t *testing.T) { + switch i { + case len(tests) - 1: + require.Panics(t, func() { DecodeStore(cdc, kvPairs[i], kvPairs[i]) }, tt.name) + default: + require.Equal(t, tt.expectedLog, DecodeStore(cdc, kvPairs[i], kvPairs[i]), tt.name) + } + }) + } +} diff --git a/x/committee/simulation/genesis.go b/x/committee/simulation/genesis.go index 22a25439..8945a4f5 100644 --- a/x/committee/simulation/genesis.go +++ b/x/committee/simulation/genesis.go @@ -2,21 +2,122 @@ package simulation import ( "fmt" + "math/rand" + "time" - "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + "github.com/cosmos/cosmos-sdk/x/simulation" "github.com/kava-labs/kava/x/committee/types" ) +const ( + // Block time params are un-exported constants in cosmos-sdk/x/simulation. + // Copy them here in lieu of importing them. + minTimePerBlock time.Duration = (10000 / 2) * time.Second + maxTimePerBlock time.Duration = 10000 * time.Second + // Calculate the average block time + AverageBlockTime time.Duration = (maxTimePerBlock - minTimePerBlock) / 2 + + FallbackCommitteeID uint64 = 0 +) + // RandomizedGenState generates a random GenesisState for the module func RandomizedGenState(simState *module.SimulationState) { + r := simState.Rand - // TODO implement this fully - // - randomly generating the genesis params - // - overwriting with genesis provided to simulation - genesisState := types.DefaultGenesisState() + // Create an always present committee with god permissions to ensure any randomly generated proposal can always be submitted. + // Without this, proposals can often not be submitted as there aren't any committees with the right set of permissions available. + // It provides more control over how often different proposal types happen during simulation. + // It also makes the code simpler--proposals can just be randomly generated and submitted without having to comply to permissions that happen to be available at the time. + fallbackCommittee := types.NewCommittee( + FallbackCommitteeID, + "A committee with god permissions that will always be in state and not deleted. It ensures any generated proposal can always be submitted and passed.", + RandomAddresses(r, simState.Accounts), + []types.Permission{types.GodPermission{}}, + sdk.MustNewDecFromStr("0.5"), + AverageBlockTime*10, + ) - fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, codec.MustMarshalJSONIndent(simState.Cdc, genesisState)) + // Create other committees + numCommittees := r.Intn(100) + committees := []types.Committee{fallbackCommittee} + for i := 0; i < numCommittees; i++ { + com, err := RandomCommittee(r, firstNAccounts(25, simState.Accounts), paramChangeToAllowedParams(simState.ParamChanges)) + if err != nil { + panic(err) + } + committees = append(committees, com) + } + + // Add genesis state to simState + genesisState := types.NewGenesisState( + types.DefaultNextProposalID, + committees, + []types.Proposal{}, + []types.Vote{}, + ) + fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, []byte{}) simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(genesisState) } + +func RandomCommittee(r *rand.Rand, availableAccs []simulation.Account, allowedParams []types.AllowedParam) (types.Committee, error) { + // pick committee members + if len(availableAccs) < 1 { + return types.Committee{}, fmt.Errorf("must be ≥ 1 addresses") + } + var members []sdk.AccAddress + for len(members) < 1 { + members = RandomAddresses(r, availableAccs) + } + + // pick proposal duration + dur, err := RandomPositiveDuration(r, 0, AverageBlockTime*10) + if err != nil { + return types.Committee{}, err + } + + // pick committee vote threshold, must be in interval (0,1] + threshold := simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1").Sub(sdk.SmallestDec())).Add(sdk.SmallestDec()) + + return types.NewCommittee( + r.Uint64(), // could collide with other committees, but unlikely + simulation.RandStringOfLength(r, r.Intn(types.MaxCommitteeDescriptionLength+1)), + members, + RandomPermissions(r, allowedParams), + threshold, + dur, + ), nil +} + +func RandomPermissions(r *rand.Rand, allowedParams []types.AllowedParam) []types.Permission { + var permissions []types.Permission + if r.Intn(100) < 50 { + permissions = append(permissions, types.TextPermission{}) + } + if r.Intn(100) < 50 { + r.Shuffle(len(allowedParams), func(i, j int) { + allowedParams[i], allowedParams[j] = allowedParams[j], allowedParams[i] + }) + permissions = append(permissions, + types.ParamChangePermission{ + AllowedParams: allowedParams[:r.Intn(len(allowedParams)+1)], + }) + } + return permissions +} + +func paramChangeToAllowedParams(paramChanges []simulation.ParamChange) []types.AllowedParam { + var allowedParams []types.AllowedParam + for _, pc := range paramChanges { + allowedParams = append( + allowedParams, + types.AllowedParam{ + Subspace: pc.Subspace, + Key: pc.Key, + }, + ) + } + return allowedParams +} diff --git a/x/committee/simulation/operations.go b/x/committee/simulation/operations.go new file mode 100644 index 00000000..71617aab --- /dev/null +++ b/x/committee/simulation/operations.go @@ -0,0 +1,199 @@ +package simulation + +import ( + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/simapp/helpers" + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/committee/keeper" + "github.com/kava-labs/kava/x/committee/types" +) + +var ( + proposalPassPercentage = 0.9 +) + +type AccountKeeper interface { + GetAccount(sdk.Context, sdk.AccAddress) authexported.Account +} + +// WeightedOperations creates an operation (with weight) for each type of proposal generator. +// Custom proposal generators can be added for more control over types of proposal submitted, eg to increase likelyhood of particular cdp param changes. +func WeightedOperations(appParams simulation.AppParams, cdc *codec.Codec, ak AccountKeeper, + k keeper.Keeper, wContents []simulation.WeightedProposalContent) simulation.WeightedOperations { + + var wops simulation.WeightedOperations + + for _, wContent := range wContents { + wContent := wContent // pin variable + if wContent.AppParamsKey == OpWeightSubmitCommitteeChangeProposal { + // don't include committee change/delete proposals as they're not enabled for submission to committees + continue + } + var weight int + // TODO this doesn't allow weights to be different from what they are in the gov module + appParams.GetOrGenerate(cdc, wContent.AppParamsKey, &weight, nil, + func(_ *rand.Rand) { weight = wContent.DefaultWeight }) + + wops = append( + wops, + simulation.NewWeightedOperation( + weight, + SimulateMsgSubmitProposal(ak, k, wContent.ContentSimulatorFn), + ), + ) + } + return wops +} + +// SimulateMsgSubmitProposal creates a proposal using the passed contentSimulatorFn and tries to find a committee that has permissions for it. If it can't then it uses the fallback committee. +// If the fallback committee isn't there (eg when using an non-generated genesis) and no committee can be found this emits a no-op msg and doesn't do anything. +// For each submit proposal msg, future ops for the vote messages are generated. Sometimes it doesn't run enough votes to allow the proposal to timeout - the likelihood of this happening is controlled by a parameter. +func SimulateMsgSubmitProposal(ak AccountKeeper, k keeper.Keeper, contentSim simulation.ContentSimulatorFn) simulation.Operation { + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string) (simulation.OperationMsg, []simulation.FutureOperation, error) { + + // 1) Send a submit proposal msg + + committees := k.GetCommittees(ctx) + // shuffle committees to ensure proposals are distributed across them evenly + r.Shuffle(len(committees), func(i, j int) { + committees[i], committees[j] = committees[j], committees[i] + }) + // move fallback committee to the end of slice + for i, c := range committees { + if c.ID == FallbackCommitteeID { + // switch places with last element + committees[i], committees[len(committees)-1] = committees[len(committees)-1], committees[i] + } + } + // pick a committee that has permissions for proposal + pp := types.PubProposal(contentSim(r, ctx, accs)) + var selectedCommittee types.Committee + var found bool + for _, c := range committees { + if c.HasPermissionsFor(pp) { + selectedCommittee = c + found = true + break + } + } + if !found { + // fallback committee was not present, this should only happen if not using the generated genesis state + return simulation.NewOperationMsgBasic(types.ModuleName, "no-operation (no committee has permissions for proposal)", "", false, nil), nil, nil + } + + // create the msg and tx + proposer := selectedCommittee.Members[r.Intn(len(selectedCommittee.Members))] // won't panic as committees must have ≥ 1 members + msg := types.NewMsgSubmitProposal( + pp, + proposer, + selectedCommittee.ID, + ) + account := ak.GetAccount(ctx, proposer) + fees, err := simulation.RandomFees(r, ctx, account.SpendableCoins(ctx.BlockTime())) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, err + } + proposerAcc, found := simulation.FindAccount(accs, proposer) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("address not in account list") + } + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas, + chainID, + []uint64{account.GetAccountNumber()}, + []uint64{account.GetSequence()}, + proposerAcc.PrivKey, + ) + // submit tx + _, result, err := app.Deliver(tx) + if err != nil { + // to aid debugging, add the stack trace to the comment field of the returned opMsg + return simulation.NewOperationMsg(msg, false, fmt.Sprintf("%+v", err)), nil, err + } + // to aid debugging, add the result log to the comment field + submitOpMsg := simulation.NewOperationMsg(msg, true, result.Log) + + // 2) Schedule vote operations + + // get submitted proposal + proposalID := types.Uint64FromBytes(result.Data) + proposal, found := k.GetProposal(ctx, proposalID) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("can't find proposal with ID '%d'", proposalID) + } + + // pick the voters + // num voters determined by whether the proposal should pass or not + numMembers := int64(len(selectedCommittee.Members)) + majority := selectedCommittee.VoteThreshold.Mul(sdk.NewInt(numMembers).ToDec()).Ceil().TruncateInt64() + + numVoters := r.Int63n(majority) // in interval [0, majority) + shouldPass := r.Float64() < proposalPassPercentage + if shouldPass { + numVoters = majority + r.Int63n(numMembers-majority+1) // in interval [majority, numMembers] + } + voters := selectedCommittee.Members[:numVoters] + + // schedule vote operations + var futureOps []simulation.FutureOperation + for _, v := range voters { + voteTime, err := RandomTime(r, ctx.BlockTime(), proposal.Deadline) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("random time generation failed: %w", err) + } + fop := simulation.FutureOperation{ + BlockTime: voteTime, + Op: SimulateMsgVote(k, ak, v, proposal.ID), + } + futureOps = append(futureOps, fop) + } + + return submitOpMsg, futureOps, nil + } +} + +func SimulateMsgVote(k keeper.Keeper, ak AccountKeeper, voter sdk.AccAddress, proposalID uint64) simulation.Operation { + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string) ( + opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { + + msg := types.NewMsgVote(voter, proposalID) + + account := ak.GetAccount(ctx, voter) + fees, err := simulation.RandomFees(r, ctx, account.SpendableCoins(ctx.BlockTime())) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, err + } + + voterAcc, found := simulation.FindAccount(accs, voter) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("address not in account list") + } + + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas, + chainID, + []uint64{account.GetAccountNumber()}, + []uint64{account.GetSequence()}, + voterAcc.PrivKey, + ) + + _, result, err := app.Deliver(tx) + if err != nil { + // to aid debugging, add the stack trace to the comment field of the returned opMsg + return simulation.NewOperationMsg(msg, false, fmt.Sprintf("%+v", err)), nil, err + } + // to aid debugging, add the result log to the comment field + return simulation.NewOperationMsg(msg, true, result.Log), nil, nil + } +} diff --git a/x/committee/simulation/params.go b/x/committee/simulation/params.go deleted file mode 100644 index 8c1f7aff..00000000 --- a/x/committee/simulation/params.go +++ /dev/null @@ -1,14 +0,0 @@ -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/committee/simulation/proposals.go b/x/committee/simulation/proposals.go new file mode 100644 index 00000000..f59504b7 --- /dev/null +++ b/x/committee/simulation/proposals.go @@ -0,0 +1,173 @@ +package simulation + +import ( + "fmt" + "math/rand" + + sdk "github.com/cosmos/cosmos-sdk/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/cosmos/cosmos-sdk/x/simulation" + + appparams "github.com/kava-labs/kava/app/params" + "github.com/kava-labs/kava/x/committee/keeper" + "github.com/kava-labs/kava/x/committee/types" +) + +const OpWeightSubmitCommitteeChangeProposal = "op_weight_submit_committee_change_proposal" + +// ProposalContents defines the module weighted proposals' contents +func ProposalContents(k keeper.Keeper, paramChanges []simulation.ParamChange) []simulation.WeightedProposalContent { + return []simulation.WeightedProposalContent{ + { + AppParamsKey: OpWeightSubmitCommitteeChangeProposal, + DefaultWeight: appparams.OpWeightSubmitCommitteeChangeProposal, + ContentSimulatorFn: SimulateCommitteeChangeProposalContent(k, paramChanges), + }, + } +} + +// SimulateCommitteeChangeProposalContent generates gov proposal contents that either: +// - create new committees +// - change existing committees +// - delete committees +// It does not alter the fallback committee. +func SimulateCommitteeChangeProposalContent(k keeper.Keeper, paramChanges []simulation.ParamChange) simulation.ContentSimulatorFn { + return func(r *rand.Rand, ctx sdk.Context, accs []simulation.Account) govtypes.Content { + allowedParams := paramChangeToAllowedParams(paramChanges) + + // get current committees, ignoring the fallback committee + var committees []types.Committee + k.IterateCommittees(ctx, func(com types.Committee) bool { + if com.ID != FallbackCommitteeID { + committees = append(committees, com) + } + return false + }) + if len(committees) < 1 { // create a committee if none exist + com, err := RandomCommittee(r, firstNAccounts(25, accs), allowedParams) // limit num members to avoid overflowing hardcoded gov ops gas limit + if err != nil { + panic(err) + } + return types.NewCommitteeChangeProposal( + simulation.RandStringOfLength(r, 10), + simulation.RandStringOfLength(r, 100), + com, + ) + } + + // create a proposal content + + var content govtypes.Content + switch choice := r.Intn(100); { + + // create committee + case choice < 20: + com, err := RandomCommittee(r, firstNAccounts(25, accs), allowedParams) // limit num members to avoid overflowing hardcoded gov ops gas limit + if err != nil { + panic(err) + } + content = types.NewCommitteeChangeProposal( + simulation.RandStringOfLength(r, 10), + simulation.RandStringOfLength(r, 100), + com, + ) + + // update committee + case choice < 80: + com := committees[r.Intn(len(committees))] + + // update members + if r.Intn(100) < 50 { + if len(accs) == 0 { + panic("must have at least one account availabel to use as committee member") + } + var members []sdk.AccAddress + for len(members) < 1 { + members = RandomAddresses(r, firstNAccounts(25, accs)) // limit num members to avoid overflowing hardcoded gov ops gas limit + } + com.Members = members + } + // update permissions + if r.Intn(100) < 50 { + com.Permissions = RandomPermissions(r, allowedParams) + } + // update proposal duration + if r.Intn(100) < 50 { + dur, err := RandomPositiveDuration(r, 0, AverageBlockTime*100) + if err != nil { + panic(err) + } + com.ProposalDuration = dur + } + // update vote threshold + if r.Intn(100) < 50 { + // VoteThreshold must be in interval (0,1] + com.VoteThreshold = simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1").Sub(sdk.SmallestDec())).Add(sdk.SmallestDec()) + } + + content = types.NewCommitteeChangeProposal( + simulation.RandStringOfLength(r, 10), + simulation.RandStringOfLength(r, 100), + com, + ) + + // delete committee + default: + com := committees[r.Intn(len(committees))] + content = types.NewCommitteeDeleteProposal( + simulation.RandStringOfLength(r, 10), + simulation.RandStringOfLength(r, 100), + com.ID, + ) + } + + return content + } +} + +/* +// Example custom ParamChangeProposal generator to only generate change to interesting cdp params. +// This allows more control over what params are changed within a simulation. +func SimulateCDPParamChangeProposalContent(cdpKeeper cdpkeeper.Keeper, paramChangePool []simulation.ParamChange) simulation.ContentSimulatorFn { + return func(r *rand.Rand, ctx sdk.Context, _ []simulation.Account) govtypes.Content { + + var paramChanges []paramstypes.ParamChange + + // alter sub params + cp := cdpKeeper.GetParams(ctx).CollateralParams + if len(cp) == 0 { + return nil + } + cp[0].StabilityFee = sdk.MustNewDecFromStr("0.000001") // TODO generate + paramChanges = append( + paramChanges, + paramstypes.NewParamChange(cdptypes.ModuleName, "?", string(cdptypes.ModuleCdc.MustMarshalJSON(cp))), + ) + + // alter normal param + for _, pc := range paramChangePool { + if pc.Subspace == cdptypes.ModuleName && pc.Key == string(cdptypes.KeyGlobalDebtLimit) { + paramChanges = append( + paramChanges, + paramstypes.NewParamChange(pc.Subspace, pc.Key, pc.SimValue(r)), + ) + } + } + + return paramstypes.NewParameterChangeProposal( + simulation.RandStringOfLength(r, 140), // title + simulation.RandStringOfLength(r, 5000), // description + paramChanges, // set of changes + ) + } +} +*/ +func firstNAccounts(n int, accs []simulation.Account) []simulation.Account { + if n < 0 { + panic(fmt.Sprintf("n must be ≥ 0")) + } + if n > len(accs) { + return accs + } + return accs[:n] +} diff --git a/x/committee/types/committee.go b/x/committee/types/committee.go index fcb54720..4d5a8d04 100644 --- a/x/committee/types/committee.go +++ b/x/committee/types/committee.go @@ -63,31 +63,30 @@ func (c Committee) Validate() error { for _, m := range c.Members { // check there are no duplicate members if _, ok := addressMap[m.String()]; ok { - return fmt.Errorf("duplicate member found in committee, %s", m) + return fmt.Errorf("committe cannot have duplicate members, %s", m) } // check for valid addresses if m.Empty() { - return fmt.Errorf("committee %d invalid: found empty member address", c.ID) + return fmt.Errorf("committee cannot have empty member address") } addressMap[m.String()] = true - } if len(c.Members) == 0 { - return fmt.Errorf("committee %d invalid: cannot have zero members", c.ID) + return fmt.Errorf("committee cannot have zero members") } if len(c.Description) > MaxCommitteeDescriptionLength { - return fmt.Errorf("invalid description") + return fmt.Errorf("description length %d longer than max allowed %d", len(c.Description), MaxCommitteeDescriptionLength) } // threshold must be in the range (0,1] if c.VoteThreshold.IsNil() || c.VoteThreshold.LTE(sdk.ZeroDec()) || c.VoteThreshold.GT(sdk.NewDec(1)) { - return fmt.Errorf("invalid threshold") + return fmt.Errorf("invalid threshold: %s", c.VoteThreshold) } if c.ProposalDuration < 0 { - return fmt.Errorf("invalid proposal duration") + return fmt.Errorf("invalid proposal duration: %s", c.ProposalDuration) } return nil diff --git a/x/committee/types/genesis.go b/x/committee/types/genesis.go index a7539a45..6ee3c4ce 100644 --- a/x/committee/types/genesis.go +++ b/x/committee/types/genesis.go @@ -65,7 +65,7 @@ func (gs GenesisState) Validate() error { } } - // validate proposals - pp.Val, no duplicate IDs, no ids >= nextID, committee needs to exist + // validate proposals proposalMap := make(map[uint64]bool, len(gs.Proposals)) for _, p := range gs.Proposals { // check there are no duplicate IDs