前置阅读

概述

本文档指导开发者将其自定义模块集成到 Cosmos SDK 的 Simulations 中。 模拟对于测试模块实现中的边界情况很有帮助。

Simulation 包

Cosmos SDK 建议将与模拟相关的代码组织在 x/<module>/simulation 包中。

Simulation App 模块

为了与 Cosmos SDK 的 SimulationManager 集成,应用模块必须实现 AppModuleSimulation 接口。
// AppModuleSimulation defines the standard functions that every module should expose
// for the SDK blockchain simulator
type AppModuleSimulation interface {
	// randomized genesis states
	GenerateGenesisState(input *SimulationState)

	// register a func to decode the each module's defined types from their corresponding store key
	RegisterStoreDecoder(simulation.StoreDecoderRegistry)

	// simulation operations (i.e msgs) with their respective weight
	WeightedOperations(simState SimulationState) []simulation.WeightedOperation
}

// HasProposalMsgs defines the messages that can be used to simulate governance (v1) proposals
type HasProposalMsgs interface {
	// msg functions used to simulate governance proposals
	ProposalMsgs(simState SimulationState) []simulation.WeightedProposalMsg
}
完整源码见 types/module/simulation.go。 可在这里查看 x/distribution 对这些方法的实现示例。

SimsX

Cosmos SDK v0.53.0 引入了一个新包 simsx,为编写模拟代码提供了更好的开发体验(DevX)。 它公开了以下扩展接口,模块可以实现这些接口以集成新的 simsx 运行器。
type (
	HasWeightedOperationsX interface {
		WeightedOperationsX(weight WeightSource, reg Registry)
	}
	HasWeightedOperationsXWithProposals interface {
		WeightedOperationsX(weights WeightSource, reg Registry, proposals WeightedProposalMsgIter,
			legacyProposals []simtypes.WeightedProposalContent)
	}
	HasProposalMsgsX interface {
		ProposalMsgsX(weights WeightSource, reg Registry)
	}
)
完整源码见 testutil/simsx/runner.go。 SimMsgFactoryFn 是大多数场景下的默认工厂。它不会创建未来操作,但会确保消息成功投递:
// SimMsgFactoryFn is the default factory for most cases. It does not create future operations but ensures successful message delivery.
type SimMsgFactoryFn[T sdk.Msg] func(ctx context.Context, testData *ChainDataSource, reporter SimulationReporter) (signer []SimAccount, msg T)
完整源码见 testutil/simsx/msg_factory.go。 这些方法可用于构造随机消息和/或提案消息。
请注意,模块不应同时实现 HasWeightedOperationsX 和 HasWeightedOperationsXWithProposals。 有关详细信息,请参见这里的运行器代码。如果模块没有消息处理器或治理提案处理器,则无需实现这些接口方法。

实现示例

  • HasWeightedOperationsXWithProposals: x/gov
  • HasWeightedOperationsX: x/bank
  • HasProposalMsgsX: x/bank

Store 解码器

注册 store 解码器是 AppImportExport 模拟所必需的。这样可以将各个 store 中的键值对解码为其对应的类型。 具体来说,它会将键匹配到具体类型,然后把 KVPair 中的值反序列化到所提供的类型。 使用 collections 的模块可以使用 NewStoreDecoderFuncFromCollectionsSchema 函数,由它为你构建解码器:
// RegisterStoreDecoder registers a decoder for supply module's types
func (am AppModule) RegisterStoreDecoder(sdr simtypes.StoreDecoderRegistry) {
	sdr[types.StoreKey] = simtypes.NewStoreDecoderFuncFromCollectionsSchema(am.keeper.(keeper.BaseKeeper).Schema)
}
完整源码见 types/simulation/collections.go,bank 模块示例见 x/bank/module.go。 未使用 collections 的模块必须手动构建 store 解码器。 可参考 distribution 模块在这里的实现示例。

随机化创世状态

模拟器会测试创世参数的不同场景和值。 应用模块必须实现 GenerateGenesisState 方法,以便根据给定种子生成初始的随机 GenesisState。 可在这里查看 x/auth 的示例。 当模块的创世参数被随机生成后(或使用 params 文件中定义的键和值),它们会被编码为 JSON 格式,并添加到用于模拟的应用创世 JSON 中。

随机加权操作

操作是 Cosmos SDK 模拟中的关键部分之一。它们是使用随机字段值进行模拟的交易(Msg)。 操作的发送方也会被随机分配。 模拟中的操作会通过暴露 BaseApp 的 ABCI 应用完整交易周期来执行模拟。

使用 Simsx

Simsx 引入了为模块的每条消息定义 MsgFactory 的能力。 这些工厂会在 WeightedOperationsX 和/或 ProposalMsgsX 中注册。
// ProposalMsgsX registers governance proposal messages in the simulation registry.
func (AppModule) ProposalMsgsX(weights simsx.WeightSource, reg simsx.Registry) {
	reg.Add(weights.Get("msg_update_params", 100), simulation.MsgUpdateParamsFactory())
}

// WeightedOperationsX registers weighted distribution module operations for simulation.
func (am AppModule) WeightedOperationsX(weights simsx.WeightSource, reg simsx.Registry) {
	reg.Add(weights.Get("msg_set_withdraw_address", 50), simulation.MsgSetWithdrawAddressFactory(am.keeper))
	reg.Add(weights.Get("msg_withdraw_delegation_reward", 50), simulation.MsgWithdrawDelegatorRewardFactory(am.keeper, am.stakingKeeper))
	reg.Add(weights.Get("msg_withdraw_validator_commission", 50), simulation.MsgWithdrawValidatorCommissionFactory(am.keeper, am.stakingKeeper))
}
请注意,传给 weights.Get 的名称必须与 WeightedOperations 中设置的操作名称一致。 例如,如果模块中包含操作 op_weight_msg_set_withdraw_address,那么传给 weights.Get 的名称应为 msg_set_withdraw_address。 可在这里查看 x/distribution 实现消息工厂的示例。

应用模拟器管理器

下一步是在应用层设置 SimulationManager。这对于下一步中的模拟测试文件是必需的。
type CoolApp struct {
	...
	sm *module.SimulationManager
}
在应用的构造函数中,使用 ModuleManager 中的模块构建模拟管理器,并调用 RegisterStoreDecoders 方法。
overrideModules := map[string]module.AppModuleSimulation{
	authtypes.ModuleName: auth.NewAppModule(app.appCodec, app.AccountKeeper, authsims.RandomGenesisAccounts, nil),
}

app.sm = module.NewSimulationManagerFromAppModules(app.ModuleManager.Modules, overrideModules)

app.sm.RegisterStoreDecoders()
请注意,你可以覆盖某些模块。 如果 ModuleManager 中现有的模块配置在 SimulationManager 中应当不同,这会很有用。 最后,应用应通过 AppI 接口中定义的以下方法暴露 SimulationManager:
// SimulationManager implements the SimulationApp interface
func (app *SimApp) SimulationManager() *module.SimulationManager {
	return app.sm
}
完整的 simapp 设置见 simapp/app.go。

运行模拟

要运行模拟,请使用 simsx 运行器。 调用 simsx.Run 以使用默认种子开始模拟,或调用 simsx.RunWithSeeds 提供特定种子:
func TestFullAppSimulation(t *testing.T) {
	sims.Run(t, NewSimApp, setupStateFactory)
}

func TestAppImportExport(t *testing.T) {
	sims.Run(t, NewSimApp, setupStateFactory, func(tb testing.TB, ti sims.TestInstance[*SimApp], accs []simtypes.Account) {
		// post-run assertions: export and compare stores
	})
}
这些函数应在测试中调用(即 app_test.go、app_sim_test.go 等)。 完整的 simapp 测试文件见 simapp/sim_test.go。

模拟测试类型

模拟框架提供了四个测试函数,每个函数都测试一种不同的失败场景:
  • TestFullAppSimulation: 常规模拟模式。按给定区块数运行链和指定操作,并检查是否发生 panic。
  • TestAppImportExport: 导出初始应用状态,并使用导出的 genesis.json 作为输入创建一个新应用,检查两者之间是否存在 store 不一致。
  • TestAppSimulationAfterImport: 将两次模拟串联起来,第一次会把其应用状态提供给第二次。适用于测试来自运行中链的软件升级或硬分叉。
  • TestAppStateDeterminism: 检查所有节点是否以相同顺序返回相同的值。

模拟器模式

模拟以三种模式运行:
  1. 完全随机 — 初始状态、模块参数和模拟参数都以伪随机方式生成。
  2. 来自 genesis.json 文件 — 初始状态和模块参数由该文件定义。适用于针对已知状态进行测试,例如运行中网络的导出状态。
  3. 来自 params.json 文件 — 初始状态以伪随机方式生成,但模块参数和模拟参数是手动设置的。可用参数列在这里。
这些模式并非互斥。例如,你可以将随机生成的创世状态(模式 1)与手动定义的模拟参数(模式 3)结合使用。

通过 go test 运行

也可以直接使用 go test 运行模拟:
go test -mod=readonly github.com/cosmos/cosmos-sdk/simapp \
  -run=TestApp<simulation_command> \
  ...<flags> \
  -v -timeout 24h
可用标志的完整列表定义在这里。Makefile 示例可参见 Cosmos SDK 的 Makefile。

调试建议

当遇到模拟失败时:
  • 使用 -ExportStatePath 标志,在失败发生的高度导出应用状态。
  • 使用 -Verbose 日志,以更完整地了解所有相关操作。
  • 尝试不同的 -Seed。如果相同错误能更早复现,你在每次运行上花费的时间就会更少。
  • 减少 -NumBlocks,以便定位失败前一个区块时的应用状态。
  • 为未记录日志的操作添加 Logger。

Prerequisite Readings

Synopsis

This document guides developers on integrating their custom modules with the Cosmos SDK Simulations. Simulations are useful for testing edge cases in module implementations.

Simulation Package

The Cosmos SDK suggests organizing your simulation related code in a x/<module>/simulation package.

Simulation App Module

To integrate with the Cosmos SDK SimulationManager, app modules must implement the AppModuleSimulation interface.
// AppModuleSimulation defines the standard functions that every module should expose
// for the SDK blockchain simulator
type AppModuleSimulation interface {
	// randomized genesis states
	GenerateGenesisState(input *SimulationState)

	// register a func to decode the each module's defined types from their corresponding store key
	RegisterStoreDecoder(simulation.StoreDecoderRegistry)

	// simulation operations (i.e msgs) with their respective weight
	WeightedOperations(simState SimulationState) []simulation.WeightedOperation
}

// HasProposalMsgs defines the messages that can be used to simulate governance (v1) proposals
type HasProposalMsgs interface {
	// msg functions used to simulate governance proposals
	ProposalMsgs(simState SimulationState) []simulation.WeightedProposalMsg
}
See the full source at types/module/simulation.go. See an example implementation of these methods from x/distribution here.

SimsX

Cosmos SDK v0.53.0 introduced a new package, simsx, providing improved DevX for writing simulation code. It exposes the following extension interfaces that modules may implement to integrate with the new simsx runner.
type (
	HasWeightedOperationsX interface {
		WeightedOperationsX(weight WeightSource, reg Registry)
	}
	HasWeightedOperationsXWithProposals interface {
		WeightedOperationsX(weights WeightSource, reg Registry, proposals WeightedProposalMsgIter,
			legacyProposals []simtypes.WeightedProposalContent)
	}
	HasProposalMsgsX interface {
		ProposalMsgsX(weights WeightSource, reg Registry)
	}
)
See the full source at testutil/simsx/runner.go. SimMsgFactoryFn is the default factory for most cases. It does not create future operations but ensures successful message delivery:
// SimMsgFactoryFn is the default factory for most cases. It does not create future operations but ensures successful message delivery.
type SimMsgFactoryFn[T sdk.Msg] func(ctx context.Context, testData *ChainDataSource, reporter SimulationReporter) (signer []SimAccount, msg T)
See the full source at testutil/simsx/msg_factory.go. These methods allow constructing randomized messages and/or proposal messages.
Note that modules should not implement both HasWeightedOperationsX and HasWeightedOperationsXWithProposals. See the runner code here for detailsIf the module does not have message handlers or governance proposal handlers, these interface methods do not need to be implemented.

Example Implementations

  • HasWeightedOperationsXWithProposals: x/gov
  • HasWeightedOperationsX: x/bank
  • HasProposalMsgsX: x/bank

Store decoders

Registering the store decoders is required for the AppImportExport simulation. This allows for the key-value pairs from the stores to be decoded to their corresponding types. In particular, it matches the key to a concrete type and then unmarshalls the value from the KVPair to the type provided. Modules using collections can use the NewStoreDecoderFuncFromCollectionsSchema function that builds the decoder for you:
// RegisterStoreDecoder registers a decoder for supply module's types
func (am AppModule) RegisterStoreDecoder(sdr simtypes.StoreDecoderRegistry) {
	sdr[types.StoreKey] = simtypes.NewStoreDecoderFuncFromCollectionsSchema(am.keeper.(keeper.BaseKeeper).Schema)
}
See the full source at types/simulation/collections.go and the bank module example at x/bank/module.go. Modules not using collections must manually build the store decoder. See the implementation here from the distribution module for an example.

Randomized genesis

The simulator tests different scenarios and values for genesis parameters. App modules must implement a GenerateGenesisState method to generate the initial random GenesisState from a given seed. See an example from x/auth here. Once the module’s genesis parameters are generated randomly (or with the key and values defined in a params file), they are marshaled to JSON format and added to the app genesis JSON for the simulation.

Random weighted operations

Operations are one of the crucial parts of the Cosmos SDK simulation. They are the transactions (Msg) that are simulated with random field values. The sender of the operation is also assigned randomly. Operations on the simulation are simulated using the full transaction cycle of a ABCI application that exposes the BaseApp.

Using Simsx

Simsx introduces the ability to define a MsgFactory for each of a module’s messages. These factories are registered in WeightedOperationsX and/or ProposalMsgsX.
// ProposalMsgsX registers governance proposal messages in the simulation registry.
func (AppModule) ProposalMsgsX(weights simsx.WeightSource, reg simsx.Registry) {
	reg.Add(weights.Get("msg_update_params", 100), simulation.MsgUpdateParamsFactory())
}

// WeightedOperationsX registers weighted distribution module operations for simulation.
func (am AppModule) WeightedOperationsX(weights simsx.WeightSource, reg simsx.Registry) {
	reg.Add(weights.Get("msg_set_withdraw_address", 50), simulation.MsgSetWithdrawAddressFactory(am.keeper))
	reg.Add(weights.Get("msg_withdraw_delegation_reward", 50), simulation.MsgWithdrawDelegatorRewardFactory(am.keeper, am.stakingKeeper))
	reg.Add(weights.Get("msg_withdraw_validator_commission", 50), simulation.MsgWithdrawValidatorCommissionFactory(am.keeper, am.stakingKeeper))
}
Note that the name passed in to weights.Get must match the name of the operation set in the WeightedOperations. For example, if the module contains an operation op_weight_msg_set_withdraw_address, the name passed to weights.Get should be msg_set_withdraw_address. See the x/distribution for an example of implementing message factories here

App Simulator manager

The following step is setting up the SimulationManager at the app level. This is required for the simulation test files in the next step.
type CoolApp struct {
	...
	sm *module.SimulationManager
}
Within the constructor of the application, construct the simulation manager using the modules from ModuleManager and call the RegisterStoreDecoders method.
overrideModules := map[string]module.AppModuleSimulation{
	authtypes.ModuleName: auth.NewAppModule(app.appCodec, app.AccountKeeper, authsims.RandomGenesisAccounts, nil),
}

app.sm = module.NewSimulationManagerFromAppModules(app.ModuleManager.Modules, overrideModules)

app.sm.RegisterStoreDecoders()
Note that you may override some modules. This is useful if the existing module configuration in the ModuleManager should be different in the SimulationManager. Finally, the application should expose the SimulationManager via the following method defined in the AppI interface:
// SimulationManager implements the SimulationApp interface
func (app *SimApp) SimulationManager() *module.SimulationManager {
	return app.sm
}
See the full simapp setup at simapp/app.go.

Running Simulations

To run the simulation, use the simsx runner. Call simsx.Run to begin simulating with the default seeds, or simsx.RunWithSeeds to provide specific seeds:
func TestFullAppSimulation(t *testing.T) {
	sims.Run(t, NewSimApp, setupStateFactory)
}

func TestAppImportExport(t *testing.T) {
	sims.Run(t, NewSimApp, setupStateFactory, func(tb testing.TB, ti sims.TestInstance[*SimApp], accs []simtypes.Account) {
		// post-run assertions: export and compare stores
	})
}
These functions should be called in tests (i.e., app_test.go, app_sim_test.go, etc.). See the full simapp test file at simapp/sim_test.go.

Simulation test types

The simulation framework provides four test functions, each testing a different failure scenario:
  • TestFullAppSimulation: General simulation mode. Runs the chain and specified operations for a given number of blocks, checking for panics.
  • TestAppImportExport: Exports the initial app state and creates a new app with the exported genesis.json as input, checking for store inconsistencies between the two.
  • TestAppSimulationAfterImport: Chains two simulations — the first provides its app state to the second. Useful for testing software upgrades or hard-forks from a live chain.
  • TestAppStateDeterminism: Checks that all nodes return the same values in the same order.

Simulator modes

Simulations run in three modes:
  1. Fully random — initial state, module parameters, and simulation parameters are all pseudo-randomly generated.
  2. From a genesis.json file — initial state and module parameters are defined by the file. Useful for testing against a known state such as a live network export.
  3. From a params.json file — initial state is pseudo-randomly generated but module and simulation parameters are set manually. Available parameters are listed here.
These modes are not mutually exclusive. For example, you can combine a randomly generated genesis state (mode 1) with manually defined simulation params (mode 3).

Running via go test

Simulations can be run directly with go test:
go test -mod=readonly github.com/cosmos/cosmos-sdk/simapp \
  -run=TestApp<simulation_command> \
  ...<flags> \
  -v -timeout 24h
The full list of available flags is defined here. For Makefile examples, see the Cosmos SDK Makefile.

Debugging tips

When encountering a simulation failure:
  • Export app state at the failure height using the -ExportStatePath flag.
  • Use -Verbose logs for a fuller picture of all operations involved.
  • Try a different -Seed. If the same error reproduces sooner, you will spend less time on each run.
  • Reduce -NumBlocks to isolate what the app state looks like at the block before failure.
  • Add a Logger to operations that are not being logged.