diff --git a/simapp/app.go b/simapp/app.go index 6999b0cd58..3598f9de1f 100644 --- a/simapp/app.go +++ b/simapp/app.go @@ -391,6 +391,7 @@ func NewSimApp( params.NewAppModule(app.ParamsKeeper), evidence.NewAppModule(app.EvidenceKeeper), authzmodule.NewAppModule(appCodec, app.AuthzKeeper, app.AccountKeeper, app.BankKeeper, app.interfaceRegistry), + nftmodule.NewAppModule(appCodec, app.NFTKeeper, app.AccountKeeper, app.BankKeeper, app.interfaceRegistry), ) app.sm.RegisterStoreDecoders() diff --git a/x/nft/module/module.go b/x/nft/module/module.go index f1cd27b53f..0b456e55c3 100644 --- a/x/nft/module/module.go +++ b/x/nft/module/module.go @@ -3,6 +3,7 @@ package nft import ( "context" "encoding/json" + "math/rand" "github.com/gorilla/mux" "github.com/grpc-ecosystem/grpc-gateway/runtime" @@ -15,15 +16,18 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/cosmos/cosmos-sdk/types/module" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" "github.com/cosmos/cosmos-sdk/x/nft" "github.com/cosmos/cosmos-sdk/x/nft/client/cli" "github.com/cosmos/cosmos-sdk/x/nft/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/simulation" ) var ( - _ module.AppModule = AppModule{} - _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModuleSimulation = AppModule{} ) // AppModuleBasic defines the basic application module used by the nft module. @@ -159,3 +163,37 @@ func (am AppModule) BeginBlock(ctx sdk.Context, req abci.RequestBeginBlock) {} func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { return []abci.ValidatorUpdate{} } + +// ____________________________________________________________________________ + +// AppModuleSimulation functions + +// GenerateGenesisState creates a randomized GenState of the nft module. +func (AppModule) GenerateGenesisState(simState *module.SimulationState) { + simulation.RandomizedGenState(simState) +} + +// ProposalContents returns all the nft content functions used to +// simulate governance proposals. +func (am AppModule) ProposalContents(simState module.SimulationState) []simtypes.WeightedProposalContent { + return nil +} + +// RandomizedParams creates randomized nft param changes for the simulator. +func (AppModule) RandomizedParams(r *rand.Rand) []simtypes.ParamChange { + return nil +} + +// RegisterStoreDecoder registers a decoder for nft module's types +func (am AppModule) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { + sdr[keeper.StoreKey] = simulation.NewDecodeStore(am.cdc) +} + +// WeightedOperations returns the all the nft module operations with their respective weights. +func (am AppModule) WeightedOperations(simState module.SimulationState) []simtypes.WeightedOperation { + return simulation.WeightedOperations( + am.registry, + simState.AppParams, simState.Cdc, + am.accountKeeper, am.bankKeeper, am.keeper, + ) +} diff --git a/x/nft/simulation/decoder.go b/x/nft/simulation/decoder.go new file mode 100644 index 0000000000..3af35a539e --- /dev/null +++ b/x/nft/simulation/decoder.go @@ -0,0 +1,45 @@ +package simulation + +import ( + "bytes" + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/kv" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/keeper" +) + +// NewDecodeStore returns a decoder function closure that umarshals the KVPair's +// Value to the corresponding nft type. +func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { + return func(kvA, kvB kv.Pair) string { + switch { + case bytes.Equal(kvA.Key[:1], keeper.ClassKey): + var classA, classB nft.Class + cdc.MustUnmarshal(kvA.Value, &classA) + cdc.MustUnmarshal(kvB.Value, &classB) + return fmt.Sprintf("%v\n%v", classA, classB) + case bytes.Equal(kvA.Key[:1], keeper.NFTKey): + var nftA, nftB nft.NFT + cdc.MustUnmarshal(kvA.Value, &nftA) + cdc.MustUnmarshal(kvB.Value, &nftB) + return fmt.Sprintf("%v\n%v", nftA, nftB) + case bytes.Equal(kvA.Key[:1], keeper.NFTOfClassByOwnerKey): + return fmt.Sprintf("%v\n%v", kvA.Value, kvB.Value) + case bytes.Equal(kvA.Key[:1], keeper.OwnerKey): + var ownerA, ownerB sdk.AccAddress + ownerA = sdk.AccAddress(kvA.Value) + ownerB = sdk.AccAddress(kvB.Value) + return fmt.Sprintf("%v\n%v", ownerA, ownerB) + case bytes.Equal(kvA.Key[:1], keeper.ClassTotalSupply): + var supplyA, supplyB uint64 + supplyA = sdk.BigEndianToUint64(kvA.Value) + supplyB = sdk.BigEndianToUint64(kvB.Value) + return fmt.Sprintf("%v\n%v", supplyA, supplyB) + default: + panic(fmt.Sprintf("invalid nft key %X", kvA.Key)) + } + } +} diff --git a/x/nft/simulation/decoder_test.go b/x/nft/simulation/decoder_test.go new file mode 100644 index 0000000000..108e424b9a --- /dev/null +++ b/x/nft/simulation/decoder_test.go @@ -0,0 +1,84 @@ +package simulation_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + "github.com/cosmos/cosmos-sdk/simapp" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/kv" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/simulation" +) + +var ( + ownerPk1 = ed25519.GenPrivKey().PubKey() + ownerAddr1 = sdk.AccAddress(ownerPk1.Address()) +) + +func TestDecodeStore(t *testing.T) { + cdc := simapp.MakeTestEncodingConfig().Codec + dec := simulation.NewDecodeStore(cdc) + + class := nft.Class{ + Id: "ClassID", + Name: "ClassName", + Symbol: "ClassSymbol", + Description: "ClassDescription", + Uri: "ClassURI", + } + classBz, err := cdc.Marshal(&class) + require.NoError(t, err) + + nft := nft.NFT{ + ClassId: "ClassID", + Id: "NFTID", + Uri: "NFTURI", + } + nftBz, err := cdc.Marshal(&nft) + require.NoError(t, err) + + nftOfClassByOwnerValue := []byte{0x01} + + totalSupply := 1 + totalSupplyBz := sdk.Uint64ToBigEndian(1) + + kvPairs := kv.Pairs{ + Pairs: []kv.Pair{ + {Key: []byte(keeper.ClassKey), Value: classBz}, + {Key: []byte(keeper.NFTKey), Value: nftBz}, + {Key: []byte(keeper.NFTOfClassByOwnerKey), Value: nftOfClassByOwnerValue}, + {Key: []byte(keeper.OwnerKey), Value: ownerAddr1}, + {Key: []byte(keeper.ClassTotalSupply), Value: totalSupplyBz}, + {Key: []byte{0x99}, Value: []byte{0x99}}, + }, + } + + tests := []struct { + name string + expectErr bool + expectedLog string + }{ + {"Class", false, fmt.Sprintf("%v\n%v", class, class)}, + {"NFT", false, fmt.Sprintf("%v\n%v", nft, nft)}, + {"NFTOfClassByOwnerKey", false, fmt.Sprintf("%v\n%v", nftOfClassByOwnerValue, nftOfClassByOwnerValue)}, + {"OwnerKey", false, fmt.Sprintf("%v\n%v", ownerAddr1, ownerAddr1)}, + {"ClassTotalSupply", false, fmt.Sprintf("%v\n%v", totalSupply, totalSupply)}, + {"other", true, ""}, + } + + for i, tt := range tests { + i, tt := i, tt + t.Run(tt.name, func(t *testing.T) { + if tt.expectErr { + require.Panics(t, func() { dec(kvPairs.Pairs[i], kvPairs.Pairs[i]) }, tt.name) + } else { + require.Equal(t, tt.expectedLog, dec(kvPairs.Pairs[i], kvPairs.Pairs[i]), tt.name) + } + }) + } +} diff --git a/x/nft/simulation/genesis.go b/x/nft/simulation/genesis.go new file mode 100644 index 0000000000..b11b358746 --- /dev/null +++ b/x/nft/simulation/genesis.go @@ -0,0 +1,67 @@ +package simulation + +import ( + "math/rand" + + "github.com/cosmos/cosmos-sdk/types/module" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/x/nft" +) + +// genClasses returns a slice of nft class. +func genClasses(r *rand.Rand, accounts []simtypes.Account) []*nft.Class { + classes := make([]*nft.Class, len(accounts)-1) + for i := 0; i < len(accounts)-1; i++ { + classes[i] = &nft.Class{ + Id: simtypes.RandStringOfLength(r, 10), + Name: simtypes.RandStringOfLength(r, 10), + Symbol: simtypes.RandStringOfLength(r, 10), + Description: simtypes.RandStringOfLength(r, 10), + Uri: simtypes.RandStringOfLength(r, 10), + } + } + return classes +} + +// genNFT returns a slice of nft. +func genNFT(r *rand.Rand, classID string, accounts []simtypes.Account) []*nft.Entry { + entries := make([]*nft.Entry, len(accounts)-1) + for i := 0; i < len(accounts)-1; i++ { + owner := accounts[i] + entries[i] = &nft.Entry{ + Owner: owner.Address.String(), + Nfts: []*nft.NFT{ + { + ClassId: classID, + Id: simtypes.RandStringOfLength(r, 10), + Uri: simtypes.RandStringOfLength(r, 10), + }, + }, + } + } + return entries +} + +// RandomizedGenState generates a random GenesisState for nft. +func RandomizedGenState(simState *module.SimulationState) { + var classes []*nft.Class + simState.AppParams.GetOrGenerate( + simState.Cdc, "nft", &classes, simState.Rand, + func(r *rand.Rand) { classes = genClasses(r, simState.Accounts) }, + ) + + var entries []*nft.Entry + simState.AppParams.GetOrGenerate( + simState.Cdc, "nft", &entries, simState.Rand, + func(r *rand.Rand) { + class := classes[r.Int63n(int64(len(classes)))] + entries = genNFT(r, class.Id, simState.Accounts) + }, + ) + + nftGenesis := &nft.GenesisState{ + Classes: classes, + Entries: entries, + } + simState.GenState[nft.ModuleName] = simState.Cdc.MustMarshalJSON(nftGenesis) +} diff --git a/x/nft/simulation/genesis_test.go b/x/nft/simulation/genesis_test.go new file mode 100644 index 0000000000..cfd3d1f6fd --- /dev/null +++ b/x/nft/simulation/genesis_test.go @@ -0,0 +1,39 @@ +package simulation_test + +import ( + "encoding/json" + "math/rand" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/simapp" + "github.com/cosmos/cosmos-sdk/types/module" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/simulation" +) + +func TestRandomizedGenState(t *testing.T) { + app := simapp.Setup(t, false) + + s := rand.NewSource(1) + r := rand.New(s) + + simState := module.SimulationState{ + AppParams: make(simtypes.AppParams), + Cdc: app.AppCodec(), + Rand: r, + NumBonded: 3, + Accounts: simtypes.RandomAccounts(r, 3), + InitialStake: 1000, + GenState: make(map[string]json.RawMessage), + } + + simulation.RandomizedGenState(&simState) + var nftGenesis nft.GenesisState + simState.Cdc.MustUnmarshalJSON(simState.GenState[nft.ModuleName], &nftGenesis) + + require.Len(t, nftGenesis.Classes, len(simState.Accounts)-1) + require.Len(t, nftGenesis.Entries, len(simState.Accounts)-1) +} diff --git a/x/nft/simulation/operations.go b/x/nft/simulation/operations.go new file mode 100644 index 0000000000..e793e86dc9 --- /dev/null +++ b/x/nft/simulation/operations.go @@ -0,0 +1,164 @@ +package simulation + +import ( + "math/rand" + + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/codec" + cdctypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/simapp/helpers" + simappparams "github.com/cosmos/cosmos-sdk/simapp/params" + sdk "github.com/cosmos/cosmos-sdk/types" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/x/nft" + + "github.com/cosmos/cosmos-sdk/x/nft/keeper" + + "github.com/cosmos/cosmos-sdk/x/simulation" +) + +const ( + // Simulation operation weights constants + OpWeightMsgSend = "op_weight_msg_send" +) + +const ( + // nft operations weights + WeightSend = 100 +) + +var ( + TypeMsgSend = sdk.MsgTypeURL(&nft.MsgSend{}) +) + +// WeightedOperations returns all the operations from the module with their respective weights +func WeightedOperations( + registry cdctypes.InterfaceRegistry, + appParams simtypes.AppParams, + cdc codec.JSONCodec, + ak nft.AccountKeeper, + bk nft.BankKeeper, + k keeper.Keeper) simulation.WeightedOperations { + + var ( + weightMsgSend int + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgSend, &weightMsgSend, nil, + func(_ *rand.Rand) { + weightMsgSend = WeightSend + }, + ) + + return simulation.WeightedOperations{ + simulation.NewWeightedOperation( + weightMsgSend, + SimulateMsgSend(codec.NewProtoCodec(registry), ak, bk, k), + ), + } +} + +// SimulateMsgSend generates a MsgSend with random values. +func SimulateMsgSend( + cdc *codec.ProtoCodec, + ak nft.AccountKeeper, + bk nft.BankKeeper, + 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) { + sender, _ := simtypes.RandomAcc(r, accs) + receiver, _ := simtypes.RandomAcc(r, accs) + + if sender.Address.Equals(receiver.Address) { + return simtypes.NoOpMsg(nft.ModuleName, TypeMsgSend, "sender and receiver are same"), nil, nil + } + + senderAcc := ak.GetAccount(ctx, sender.Address) + spendableCoins := bk.SpendableCoins(ctx, sender.Address) + fees, err := simtypes.RandomFees(r, ctx, spendableCoins) + if err != nil { + return simtypes.NoOpMsg(nft.ModuleName, TypeMsgSend, err.Error()), nil, err + } + + spendLimit := spendableCoins.Sub(fees) + if spendLimit == nil { + return simtypes.NoOpMsg(nft.ModuleName, TypeMsgSend, "spend limit is nil"), nil, nil + } + + n, err := randNFT(ctx, r, k, senderAcc.GetAddress()) + if err != nil { + return simtypes.NoOpMsg(nft.ModuleName, TypeMsgSend, err.Error()), nil, err + } + + msg := &nft.MsgSend{ + ClassId: n.ClassId, + Id: n.Id, + Sender: senderAcc.GetAddress().String(), + Receiver: receiver.Address.String(), + } + + txCfg := simappparams.MakeTestEncodingConfig().TxConfig + tx, err := helpers.GenTx( + txCfg, + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas, + chainID, + []uint64{senderAcc.GetAccountNumber()}, + []uint64{senderAcc.GetSequence()}, + sender.PrivKey, + ) + if err != nil { + return simtypes.NoOpMsg(nft.ModuleName, TypeMsgSend, "unable to generate mock tx"), nil, err + } + + _, _, err = app.SimDeliver(txCfg.TxEncoder(), tx) + if err != nil { + return simtypes.NoOpMsg(nft.ModuleName, sdk.MsgTypeURL(msg), "unable to deliver tx"), nil, err + } + + return simtypes.NewOperationMsg(msg, true, "", cdc), nil, err + } +} + +func randNFT(ctx sdk.Context, r *rand.Rand, k keeper.Keeper, minter sdk.AccAddress) (nft.NFT, error) { + c, err := randClass(ctx, r, k) + if err != nil { + return nft.NFT{}, err + } + ns := k.GetNFTsOfClassByOwner(ctx, c.Id, minter) + if len(ns) > 0 { + return ns[r.Intn(len(ns))], nil + } + + n := nft.NFT{ + ClassId: c.Id, + Id: simtypes.RandStringOfLength(r, 10), + Uri: simtypes.RandStringOfLength(r, 10), + } + err = k.Mint(ctx, n, minter) + if err != nil { + return nft.NFT{}, err + } + return n, nil +} + +func randClass(ctx sdk.Context, r *rand.Rand, k keeper.Keeper) (nft.Class, error) { + classes := k.GetClasses(ctx) + if len(classes) == 0 { + c := nft.Class{ + Id: simtypes.RandStringOfLength(r, 10), + Name: simtypes.RandStringOfLength(r, 10), + Symbol: simtypes.RandStringOfLength(r, 10), + Description: simtypes.RandStringOfLength(r, 10), + Uri: simtypes.RandStringOfLength(r, 10), + } + err := k.SaveClass(ctx, c) + if err != nil { + return nft.Class{}, err + } + return c, nil + } + return *classes[r.Intn(len(classes))], nil +} diff --git a/x/nft/simulation/operations_test.go b/x/nft/simulation/operations_test.go new file mode 100644 index 0000000000..dd222ecbbc --- /dev/null +++ b/x/nft/simulation/operations_test.go @@ -0,0 +1,115 @@ +package simulation_test + +import ( + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + abci "github.com/tendermint/tendermint/abci/types" + tmproto "github.com/tendermint/tendermint/proto/tendermint/types" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/simapp" + sdk "github.com/cosmos/cosmos-sdk/types" + simtypes "github.com/cosmos/cosmos-sdk/types/simulation" + "github.com/cosmos/cosmos-sdk/x/bank/testutil" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/simulation" +) + +type SimTestSuite struct { + suite.Suite + + ctx sdk.Context + app *simapp.SimApp +} + +func (suite *SimTestSuite) SetupTest() { + checkTx := false + app := simapp.Setup(suite.T(), checkTx) + suite.app = app + suite.ctx = app.BaseApp.NewContext(checkTx, tmproto.Header{}) +} + +func (suite *SimTestSuite) TestWeightedOperations() { + weightedOps := simulation.WeightedOperations( + suite.app.InterfaceRegistry(), + make(simtypes.AppParams), + suite.app.AppCodec(), + suite.app.AccountKeeper, + suite.app.BankKeeper, suite.app.NFTKeeper, + ) + + // setup 3 accounts + s := rand.NewSource(1) + r := rand.New(s) + accs := suite.getTestingAccounts(r, 3) + + expected := []struct { + weight int + opMsgRoute string + opMsgName string + }{ + {simulation.WeightSend, simulation.TypeMsgSend, simulation.TypeMsgSend}, + } + + for i, w := range weightedOps { + operationMsg, _, _ := w.Op()(r, suite.app.BaseApp, suite.ctx, accs, "") + // the following checks are very much dependent from the ordering of the output given + // by WeightedOperations. if the ordering in WeightedOperations changes some tests + // will fail + suite.Require().Equal(expected[i].weight, w.Weight(), "weight should be the same") + suite.Require().Equal(expected[i].opMsgRoute, operationMsg.Route, "route should be the same") + suite.Require().Equal(expected[i].opMsgName, operationMsg.Name, "operation Msg name should be the same") + } +} + +func (suite *SimTestSuite) getTestingAccounts(r *rand.Rand, n int) []simtypes.Account { + accounts := simtypes.RandomAccounts(r, n) + + initAmt := suite.app.StakingKeeper.TokensFromConsensusPower(suite.ctx, 200000) + initCoins := sdk.NewCoins(sdk.NewCoin("stake", initAmt)) + + // add coins to the accounts + for _, account := range accounts { + acc := suite.app.AccountKeeper.NewAccountWithAddress(suite.ctx, account.Address) + suite.app.AccountKeeper.SetAccount(suite.ctx, acc) + suite.Require().NoError(testutil.FundAccount(suite.app.BankKeeper, suite.ctx, account.Address, initCoins)) + } + + return accounts +} + +func (suite *SimTestSuite) TestSimulateMsgSend() { + s := rand.NewSource(1) + r := rand.New(s) + accounts := suite.getTestingAccounts(r, 2) + blockTime := time.Now().UTC() + ctx := suite.ctx.WithBlockTime(blockTime) + + // begin a new block + suite.app.BeginBlock(abci.RequestBeginBlock{ + Header: tmproto.Header{ + Height: suite.app.LastBlockHeight() + 1, + AppHash: suite.app.LastCommitID().Hash, + }, + }) + + // execute operation + registry := suite.app.InterfaceRegistry() + op := simulation.SimulateMsgSend(codec.NewProtoCodec(registry), suite.app.AccountKeeper, suite.app.BankKeeper, suite.app.NFTKeeper) + operationMsg, futureOperations, err := op(r, suite.app.BaseApp, ctx, accounts, "") + suite.Require().NoError(err) + + var msg nft.MsgSend + suite.app.AppCodec().UnmarshalJSON(operationMsg.Msg, &msg) + suite.Require().True(operationMsg.OK) + suite.Require().Len(futureOperations, 0) + +} + +func TestSimTestSuite(t *testing.T) { + suite.Run(t, new(SimTestSuite)) +}