cosmos-sdk/x/gov/simulation/msg_factory.go
2025-04-14 17:30:59 +00:00

371 lines
12 KiB
Go

package simulation
import (
"context"
"math"
"math/rand"
"sync/atomic"
"time"
sdkmath "cosmossdk.io/math"
"github.com/cosmos/cosmos-sdk/testutil/simsx"
sdk "github.com/cosmos/cosmos-sdk/types"
simtypes "github.com/cosmos/cosmos-sdk/types/simulation"
"github.com/cosmos/cosmos-sdk/x/gov/keeper"
v1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1"
"github.com/cosmos/cosmos-sdk/x/simulation"
)
func MsgDepositFactory(k *keeper.Keeper, sharedState *SharedState) simsx.SimMsgFactoryFn[*v1.MsgDeposit] {
return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *v1.MsgDeposit) {
r := testData.Rand()
proposalID, ok := randomProposalID(r.Rand, k, sdk.UnwrapSDKContext(ctx), v1.StatusDepositPeriod, sharedState)
if !ok {
reporter.Skip("no proposal in deposit state")
return nil, nil
}
proposal, err := k.Proposals.Get(ctx, proposalID)
if err != nil {
reporter.Skip(err.Error())
return nil, nil
}
// calculate deposit amount
deposit := randDeposit(ctx, proposal, k, r, reporter)
if reporter.IsSkipped() {
return nil, nil
}
from := testData.AnyAccount(reporter, simsx.WithLiquidBalanceGTE(deposit))
return []simsx.SimAccount{from}, v1.NewMsgDeposit(from.Address, proposalID, sdk.NewCoins(deposit))
}
}
func MsgVoteFactory(k *keeper.Keeper, sharedState *SharedState) simsx.SimMsgFactoryFn[*v1.MsgVote] {
return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *v1.MsgVote) {
r := testData.Rand()
proposalID, ok := randomProposalID(r.Rand, k, sdk.UnwrapSDKContext(ctx), v1.StatusVotingPeriod, sharedState)
if !ok {
reporter.Skip("no proposal in voting state")
return nil, nil
}
from := testData.AnyAccount(reporter, simsx.WithSpendableBalance())
msg := v1.NewMsgVote(from.Address, proposalID, randomVotingOption(r.Rand), "")
return []simsx.SimAccount{from}, msg
}
}
func MsgWeightedVoteFactory(k *keeper.Keeper, sharedState *SharedState) simsx.SimMsgFactoryFn[*v1.MsgVoteWeighted] {
return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *v1.MsgVoteWeighted) {
r := testData.Rand()
proposalID, ok := randomProposalID(r.Rand, k, sdk.UnwrapSDKContext(ctx), v1.StatusVotingPeriod, sharedState)
if !ok {
reporter.Skip("no proposal in deposit state")
return nil, nil
}
from := testData.AnyAccount(reporter, simsx.WithSpendableBalance())
msg := v1.NewMsgVoteWeighted(from.Address, proposalID, randomWeightedVotingOptions(r.Rand), "")
return []simsx.SimAccount{from}, msg
}
}
func MsgCancelProposalFactory(k *keeper.Keeper, sharedState *SharedState) simsx.SimMsgFactoryFn[*v1.MsgCancelProposal] {
return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *v1.MsgCancelProposal) {
r := testData.Rand()
status := simsx.OneOf(r, []v1.ProposalStatus{v1.StatusDepositPeriod, v1.StatusVotingPeriod})
proposalID, ok := randomProposalID(r.Rand, k, sdk.UnwrapSDKContext(ctx), status, sharedState)
if !ok {
reporter.Skip("no proposal in deposit state")
return nil, nil
}
proposal, err := k.Proposals.Get(ctx, proposalID)
if err != nil {
reporter.Skip(err.Error())
return nil, nil
}
// is cancellable? copied from keeper
if proposal.VotingEndTime != nil && proposal.VotingEndTime.Before(sdk.UnwrapSDKContext(ctx).BlockTime()) {
reporter.Skip("not cancellable anymore")
return nil, nil
}
from := testData.GetAccount(reporter, proposal.Proposer)
if from.LiquidBalance().Empty() {
reporter.Skip("proposer is broke")
return nil, nil
}
msg := v1.NewMsgCancelProposal(proposalID, from.AddressBech32)
return []simsx.SimAccount{from}, msg
}
}
func MsgSubmitLegacyProposalFactory(k *keeper.Keeper, contentSimFn simtypes.ContentSimulatorFn) simsx.SimMsgFactoryX { //nolint:staticcheck // used for legacy testing
return simsx.NewSimMsgFactoryWithFutureOps[*v1.MsgSubmitProposal](func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter, fOpsReg simsx.FutureOpsRegistry) ([]simsx.SimAccount, *v1.MsgSubmitProposal) {
// 1) submit proposal now
accs := testData.AllAccounts()
content := contentSimFn(testData.Rand().Rand, sdk.UnwrapSDKContext(ctx), accs)
if content == nil {
reporter.Skip("content is nil")
return nil, nil
}
govacc := must(testData.AddressCodec().BytesToString(k.GetGovernanceAccount(ctx).GetAddress()))
contentMsg := must(v1.NewLegacyContent(content, govacc))
return submitProposalWithVotesScheduled(ctx, k, testData, reporter, fOpsReg, contentMsg)
})
}
func MsgSubmitProposalFactory(k *keeper.Keeper, payloadFactory simsx.FactoryMethod) simsx.SimMsgFactoryX {
return simsx.NewSimMsgFactoryWithFutureOps[*v1.MsgSubmitProposal](func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter, fOpsReg simsx.FutureOpsRegistry) ([]simsx.SimAccount, *v1.MsgSubmitProposal) {
_, proposalMsg := payloadFactory(ctx, testData, reporter)
return submitProposalWithVotesScheduled(ctx, k, testData, reporter, fOpsReg, proposalMsg)
})
}
func submitProposalWithVotesScheduled(
ctx context.Context,
k *keeper.Keeper,
testData *simsx.ChainDataSource,
reporter simsx.SimulationReporter,
fOpsReg simsx.FutureOpsRegistry,
proposalMsgs ...sdk.Msg,
) ([]simsx.SimAccount, *v1.MsgSubmitProposal) {
r := testData.Rand()
expedited := r.Bool()
params := must(k.Params.Get(ctx))
minDeposits := params.MinDeposit
if expedited {
minDeposits = params.ExpeditedMinDeposit
}
minDeposit := r.Coin(minDeposits)
minDepositRatio := must(sdkmath.LegacyNewDecFromStr(params.GetMinDepositRatio()))
threshold := minDeposit.Amount.ToLegacyDec().Mul(minDepositRatio).TruncateInt()
minDepositPercent := must(sdkmath.LegacyNewDecFromStr(params.MinInitialDepositRatio))
minAmount := sdkmath.LegacyNewDecFromInt(minDeposit.Amount).Mul(minDepositPercent).TruncateInt()
amount, err := r.PositiveSDKIntn(minDeposit.Amount.Sub(minAmount))
if err != nil {
reporter.Skip(err.Error())
return nil, nil
}
if amount.LT(threshold) {
reporter.Skip("below threshold amount for proposal")
return nil, nil
}
deposit := minDeposit
// deposit := sdk.Coin{Amount: amount.Add(minAmount), Denom: minDeposit.Denom}
proposer := testData.AnyAccount(reporter, simsx.WithLiquidBalanceGTE(deposit))
if reporter.IsSkipped() || !proposer.LiquidBalance().BlockAmount(deposit) {
return nil, nil
}
msg, err := v1.NewMsgSubmitProposal(
proposalMsgs,
sdk.Coins{deposit},
proposer.AddressBech32,
r.StringN(100),
r.StringN(100),
r.StringN(100),
expedited,
)
if err != nil {
reporter.Skip("unable to generate a submit proposal msg")
return nil, nil
}
// futureOps
var (
// The states are:
// column 1: All validators vote
// column 2: 90% vote
// column 3: 75% vote
// column 4: 40% vote
// column 5: 15% vote
// column 6: no one votes
// All columns sum to 100 for simplicity, values chosen by @valardragon semi-arbitrarily,
// feel free to change.
numVotesTransitionMatrix = must(simulation.CreateTransitionMatrix([][]int{
{20, 10, 0, 0, 0, 0},
{55, 50, 20, 10, 0, 0},
{25, 25, 30, 25, 30, 15},
{0, 15, 30, 25, 30, 30},
{0, 0, 20, 30, 30, 30},
{0, 0, 0, 10, 10, 25},
}))
statePercentageArray = []float64{1, .9, .75, .4, .15, 0}
curNumVotesState = 1
)
// get the submitted proposal ID
proposalID := must(k.ProposalID.Peek(ctx))
// 2) Schedule operations for votes
// 2.1) first pick a number of people to vote.
curNumVotesState = numVotesTransitionMatrix.NextState(r.Rand, curNumVotesState)
numVotes := int(math.Ceil(float64(testData.AccountsCount()) * statePercentageArray[curNumVotesState]))
// 2.2) select who votes and when
whoVotes := r.Perm(testData.AccountsCount())
// didntVote := whoVotes[numVotes:]
whoVotes = whoVotes[:numVotes]
votingPeriod := params.VotingPeriod
// future ops so that votes do not flood the sims.
if r.Intn(100) == 1 { // 1% chance
now := simsx.BlockTime(ctx)
for i := range numVotes {
var vF simsx.SimMsgFactoryFn[*v1.MsgVote] = func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *v1.MsgVote) {
switch p, err := k.Proposals.Get(ctx, proposalID); {
case err != nil:
reporter.Skip(err.Error())
return nil, nil
case p.Status != v1.ProposalStatus_PROPOSAL_STATUS_VOTING_PERIOD:
reporter.Skip("proposal not in voting period")
return nil, nil
}
voter := testData.AccountAt(reporter, whoVotes[i])
msg := v1.NewMsgVote(voter.Address, proposalID, randomVotingOption(r.Rand), "")
return []simsx.SimAccount{voter}, msg
}
whenVote := now.Add(time.Duration(r.Int63n(int64(votingPeriod.Seconds()))) * time.Second)
fOpsReg.Add(whenVote, vF)
}
}
return []simsx.SimAccount{proposer}, msg
}
// TextProposalFactory returns a random text proposal content.
// A text proposal is a proposal that contains no msgs.
func TextProposalFactory() simsx.SimMsgFactoryFn[sdk.Msg] {
return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, sdk.Msg) {
return nil, nil
}
}
func randDeposit(
ctx context.Context,
proposal v1.Proposal,
k *keeper.Keeper,
r *simsx.XRand,
reporter simsx.SimulationReporter,
) sdk.Coin {
params, err := k.Params.Get(ctx)
if err != nil {
reporter.Skipf("gov params: %s", err)
return sdk.Coin{}
}
minDeposits := params.MinDeposit
if proposal.Expedited {
minDeposits = params.ExpeditedMinDeposit
}
minDeposit := simsx.OneOf(r, minDeposits)
minDepositRatio, err := sdkmath.LegacyNewDecFromStr(params.GetMinDepositRatio())
if err != nil {
reporter.Skip(err.Error())
return sdk.Coin{}
}
threshold := minDeposit.Amount.ToLegacyDec().Mul(minDepositRatio).TruncateInt()
depositAmount, err := r.PositiveSDKIntInRange(threshold, minDeposit.Amount)
if err != nil {
reporter.Skipf("deposit amount: %s", err)
return sdk.Coin{}
}
return sdk.Coin{Denom: minDeposit.Denom, Amount: depositAmount}
}
// Pick a random proposal ID between the initial proposal ID
// (defined in gov GenesisState) and the latest proposal ID
// that matches a given Status.
// It does not provide a default ID.
func randomProposalID(r *rand.Rand, k *keeper.Keeper, ctx sdk.Context, status v1.ProposalStatus, s *SharedState) (proposalID uint64, found bool) {
proposalID, _ = k.ProposalID.Peek(ctx)
if initialProposalID := s.getMinProposalID(); initialProposalID == unsetProposalID {
s.setMinProposalID(proposalID)
} else if initialProposalID < proposalID {
proposalID = uint64(simtypes.RandIntBetween(r, int(initialProposalID), int(proposalID)))
}
proposal, err := k.Proposals.Get(ctx, proposalID)
if err != nil || proposal.Status != status {
return proposalID, false
}
return proposalID, true
}
// Pick a random weighted voting options
func randomWeightedVotingOptions(r *rand.Rand) v1.WeightedVoteOptions {
w1 := r.Intn(100 + 1)
w2 := r.Intn(100 - w1 + 1)
w3 := r.Intn(100 - w1 - w2 + 1)
w4 := 100 - w1 - w2 - w3
weightedVoteOptions := v1.WeightedVoteOptions{}
if w1 > 0 {
weightedVoteOptions = append(weightedVoteOptions, &v1.WeightedVoteOption{
Option: v1.OptionYes,
Weight: sdkmath.LegacyNewDecWithPrec(int64(w1), 2).String(),
})
}
if w2 > 0 {
weightedVoteOptions = append(weightedVoteOptions, &v1.WeightedVoteOption{
Option: v1.OptionAbstain,
Weight: sdkmath.LegacyNewDecWithPrec(int64(w2), 2).String(),
})
}
if w3 > 0 {
weightedVoteOptions = append(weightedVoteOptions, &v1.WeightedVoteOption{
Option: v1.OptionNo,
Weight: sdkmath.LegacyNewDecWithPrec(int64(w3), 2).String(),
})
}
if w4 > 0 {
weightedVoteOptions = append(weightedVoteOptions, &v1.WeightedVoteOption{
Option: v1.OptionNoWithVeto,
Weight: sdkmath.LegacyNewDecWithPrec(int64(w4), 2).String(),
})
}
return weightedVoteOptions
}
func randomVotingOption(r *rand.Rand) v1.VoteOption {
switch r.Intn(4) {
case 0:
return v1.OptionYes
case 1:
return v1.OptionAbstain
case 2:
return v1.OptionNo
case 3:
return v1.OptionNoWithVeto
default:
panic("invalid vote option")
}
}
func must[T any](r T, err error) T {
if err != nil {
panic(err)
}
return r
}
const unsetProposalID = 100000000000000
// SharedState shared state between message invocations
type SharedState struct {
minProposalID atomic.Uint64
}
// NewSharedState constructor
func NewSharedState() *SharedState {
r := &SharedState{}
r.setMinProposalID(unsetProposalID)
return r
}
func (s *SharedState) getMinProposalID() uint64 {
return s.minProposalID.Load()
}
func (s *SharedState) setMinProposalID(id uint64) {
s.minProposalID.Store(id)
}