如果你是从模块构建教程来到这里的,请先切回 cosmos/example 仓库 的 main 分支:
git checkout main
你在上一节教程中构建的最小 counter,已经体现了 SDK 模块模式的核心。main 分支中的完整 x/counter 模块示例遵循相同的模式,并在此基础上增加了若干功能。 本篇演练旨在准确说明每个功能是什么、它的作用是什么,以及你如何为任意模块添加类似功能。

最小版与完整版 counter

main 分支中的完整 counter,相比教程里的最小版 counter,增加了相当多的功能。
功能最小版 x/counter完整版 x/counter
状态countcount + params
消息AddAdd + UpdateParams
查询CountCount + Params
校验无MaxAddValue 限制、溢出检查
费用无通过 bank 模块收取 AddCost
权限无受治理控制的参数更新
错误通用错误具名哨兵错误
遥测无OpenTelemetry counter 指标
CLIAutoCLIAutoCLI + EnhanceCustomCommand
模拟无simsx 加权操作
区块钩子无BeginBlock + EndBlock
单元测试无完整的 keeper/msg/query 测试套件
msg_server.go、query_server.go、module.go 和 types/ 中的接线代码,在两者之间的结构是相似的。大量新增的 keeper 逻辑集中在一个方法里:keeper.go 中的 AddCount。

Params 和 authority

模块参数 是链上配置,用来在不修改代码的情况下控制模块行为。 完整 counter 增加了一个 Params 类型,使链上治理可以在运行时配置模块行为。在完整模块中,params 控制一次 Add 可以有多大,以及它需要支付多少费用。

代码位置

试试看

你可以通过下面的命令查看当前 params:
exampled query counter params

将它加到你的模块中

如果你想为自己的模块添加可在运行时配置的 params,请做这些修改:
  1. 在 proto 中定义 Params 类型
  2. 添加一个受保护的 UpdateParams 消息
  3. 添加一个用于读取当前 params 的查询
  4. 在 keeper 中存储 params 和 authority
  5. 在写入新 params 之前,于 MsgServer 中检查 authority

state.proto

state.proto 中相关的新增内容是:
message Params {
  uint64 max_add_value = 1;
  repeated cosmos.base.v1beta1.Coin add_cost = 2 [
    (gogoproto.nullable) = false,
    (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins",
    (amino.dont_omitempty) = true
  ];
}
MaxAddValue 限制单次 Add 调用最多能让计数器增加多少。AddCost 设置每次 add 操作收取的可选费用。

tx.proto - UpdateParams

tx.proto 中相关的新增内容是:
rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse);

message MsgUpdateParams {
  option (cosmos.msg.v1.signer) = "authority";
  string authority = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
  Params params = 2 [(gogoproto.nullable) = false];
}

message MsgUpdateParamsResponse {}
UpdateParams 是一个特权消息。只有 authority 地址可以调用它。默认情况下,这个地址是治理模块账户,因此 params 只能通过治理提案来修改。

query.proto - Params

query.proto 添加了第二个查询,用于暴露当前 params:
rpc Params(QueryParamsRequest) returns (QueryParamsResponse);

authority 模式

keeper 会存储 authority 地址,并在每次 UpdateParams 调用时进行检查:
type Keeper struct {
    // ...
    // authority is the address capable of executing a MsgUpdateParams message.
    // Typically, this should be the x/gov module account.
    authority string
}
// msg_server.go
func (m msgServer) UpdateParams(ctx context.Context, msg *types.MsgUpdateParams) (*types.MsgUpdateParamsResponse, error) {
    if m.authority != msg.Authority {
        return nil, sdkerrors.Wrapf(govtypes.ErrInvalidSigner,
            "invalid authority; expected %s, got %s", m.authority, msg.Authority)
    }
    return &types.MsgUpdateParamsResponse{}, m.SetParams(ctx, msg.Params)
}
在构造 keeper 时,authority 默认设置为治理模块账户:
authority: authtypes.NewModuleAddress(govtypes.ModuleName).String(),
这种做法,即在 keeper 中存储 authority,并在 MsgServer 中检查它,是 Cosmos SDK 中用于治理受控配置的标准方式。

Expected keepers 和费用收取

这一节展示了 Cosmos SDK 中模块间交互的标准模式。x/counter 使用 expected keeper 调用 bank 模块,并为每次 add 操作收取费用。

代码位置

app.go 变更

这个功能需要对 app.go 做两处修改:
  • 在 maccPerms 中添加 countertypes.ModuleName: nil
  • 将 app.BankKeeper 传入 counterkeeper.NewKeeper(...)
在 app.go 中,这些变更如下所示:
maccPerms = map[string][]string{
    // ...
    countertypes.ModuleName: nil,
}
app.CounterKeeper = counterkeeper.NewKeeper(
    runtime.NewKVStoreService(keys[countertypes.StoreKey]),
    appCodec,
    app.BankKeeper,
)

试试看

提交一笔 add 交易后,配置好的 AddCost 费用会从发送者账户中扣除:
exampled tx counter add 5 --from alice --chain-id demo --yes

将它加到你的模块中

如果你想通过 bank 模块添加费用收取功能,请做这些修改:
  1. 在 types/expected_keepers.go 中定义一个精简的 bank keeper 接口
  2. 在 keeper 中添加 bankKeeper 字段
  3. 在 keeper 的业务逻辑中收取费用
  4. 在 maccPerms 中添加模块账户条目
  5. 在 app.go 中将 app.BankKeeper 传入 keeper 构造函数

expected_keepers.go

counter 模块并不直接导入 bank 模块,而是定义了自己所需的最小接口:
// x/counter/types/expected_keepers.go
type BankKeeper interface {
    SendCoinsFromAccountToModule(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
}
这样可以让依赖关系显式且精简。counter 模块不会意外调用其他 bank 方法。

Keeper 结构体

type Keeper struct {
    Schema     collections.Schema
    counter    collections.Item[uint64]
    params     collections.Item[types.Params]
    bankKeeper types.BankKeeper
    authority  string
}

在 AddCount 中收取费用

func (k *Keeper) AddCount(ctx context.Context, sender string, amount uint64) (uint64, error) {
    if amount >= math.MaxUint64 {
        return 0, ErrNumTooLarge
    }

    params, err := k.GetParams(ctx)
    if err != nil {
        return 0, err
    }

    if params.MaxAddValue > 0 && amount > params.MaxAddValue {
        return 0, ErrExceedsMaxAdd
    }

    if !params.AddCost.IsZero() {
        senderAddr, err := sdk.AccAddressFromBech32(sender)
        if err != nil {
            return 0, err
        }
        if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, senderAddr, types.ModuleName, params.AddCost); err != nil {
            return 0, sdkerrors.Wrap(ErrInsufficientFunds, err.Error())
        }
    }

    count, err := k.GetCount(ctx)
    if err != nil {
        return 0, err
    }

    newCount := count + amount
    if err := k.counter.Set(ctx, newCount); err != nil {
        return 0, err
    }

    sdkCtx := sdk.UnwrapSDKContext(ctx)
    sdkCtx.EventManager().EmitEvent(
        sdk.NewEvent(
            "count_increased",
            sdk.NewAttribute("count", fmt.Sprintf("%v", newCount)),
        ),
    )

    countMetric.Add(ctx, int64(amount))

    return newCount, nil
}
所有业务逻辑、校验、费用收取、状态变更、事件和遥测都放在 AddCount 中。MsgServer 本身保持精简:
func (m msgServer) Add(ctx context.Context, req *types.MsgAddRequest) (*types.MsgAddResponse, error) {
    newCount, err := m.AddCount(ctx, req.GetSender(), req.GetAdd())
    if err != nil {
        return nil, err
    }
    return &types.MsgAddResponse{UpdatedCount: newCount}, nil
}
由于 AddCount 是一个具名 keeper 方法,它不仅可以从 MsgServer 调用,也可以从 BeginBlock、治理钩子或其他模块中调用。

模块账户

模块账户是由模块而不是用户拥有的链上账户。模块使用模块账户来持有资金、接收费用,或获取诸如铸币、销毁之类的特殊权限。 由于 x/counter 需要接收用户支付的费用,它需要在 app.go 中添加一个模块账户条目:
maccPerms = map[string][]string{
    // ...
    countertypes.ModuleName: nil,
}
它位于 app.go 的 maccPerms 映射中。这里的 nil 表示该模块账户可以接收资金,但不会获得铸币或销毁之类的额外权限。

哨兵错误

x/counter 不再返回通用错误,而是定义了带有已注册错误码的具名哨兵错误。这样可以让失败原因更容易理解,也更方便客户端以编程方式进行匹配。

代码位置

// keeper/errors.go
var (
    ErrNumTooLarge       = errors.Register("counter", 0, "requested integer to add is too large")
    ErrExceedsMaxAdd     = errors.Register("counter", 1, "add value exceeds max allowed")
    ErrInsufficientFunds = errors.Register("counter", 2, "insufficient funds to pay add cost")
)
已注册的错误会在链上生成结构化错误响应,客户端可以按错误码匹配,而不只是按字符串匹配。每个错误码在模块内都必须唯一且大于零(错误码 1 保留给 SDK 内部错误)。如果要检查某个错误是否属于特定的哨兵错误类型,请使用 errors.Is(err, ErrInsufficientFunds);即使该错误已经通过 errorsmod.Wrap 或 errorsmod.Wrapf 包装并附加了额外上下文,这种方式仍然能正确工作。 所有校验,无论是无状态字段检查还是有状态业务逻辑检查,都应放在 msgServer 方法或其调用的 keeper 函数中。消息类型上较旧的 ValidateBasic 方法已被弃用:应优先在消息服务端内部完成全部校验。如果你的消息类型确实实现了 ValidateBasic,SDK 仍会出于向后兼容而调用它,但新模块不应依赖它。

遥测

Telemetry 会记录计数器被更新的频率,以便你在兼容 OpenTelemetry 的系统中观测模块活动。

代码位置

// x/counter/keeper/telemetry.go
var (
    meter = otel.Meter("github.com/cosmos/example/x/counter")

    countMetric metric.Int64Counter
)

func init() {
    var err error
    countMetric, err = meter.Int64Counter("count")
    if err != nil {
        panic(err)
    }
}
AddCount 中的 countMetric.Add(ctx, int64(amount)) 会在每次模块状态更新时递增一个 OpenTelemetry 计数器。这会让模块活动在任何兼容 OTel 的可观测系统中可见。

AutoCLI

AutoCLI 会将模块的查询和交易暴露为 CLI 命令。完整模块示例保留了与最小模块相同的基础 AutoCLI 配置,并额外加入了用于集成自定义命令的推荐设置。

代码位置

试一试

这些命令来自 AutoCLI 配置。count 和 add 在 autocli.go 中被显式自定义,而 params 仍然可通过生成的查询服务使用。
exampled query counter count
exampled query counter params
exampled tx counter add 5 --from alice --chain-id demo --yes
两个模块都使用 AutoCLI。唯一的区别是 x/counter 设置了 EnhanceCustomCommand: true,这会把任何手写 CLI 命令与自动生成的命令合并。由于这两个模块都没有手写命令,所以这里没有实际效果,但对于更完整的模块来说,这是一个很好的默认设置。 x/counter 中的 autocli.go 文件:
// autocli.go
func (a AppModule) AutoCLIOptions() *autocliv1.ModuleOptions {
    return &autocliv1.ModuleOptions{
        Query: &autocliv1.ServiceCommandDescriptor{
            Service:              "example.counter.Query",
            EnhanceCustomCommand: true,
            RpcCommandOptions: []*autocliv1.RpcCommandOptions{
                {RpcMethod: "Count", Use: "count", Short: "Query the current counter value"},
            },
        },
        Tx: &autocliv1.ServiceCommandDescriptor{
            Service:              "example.counter.Msg",
            EnhanceCustomCommand: true,
            RpcCommandOptions: []*autocliv1.RpcCommandOptions{
                {RpcMethod: "Add", Use: "add [amount]", Short: "Add to the counter",
                    PositionalArgs: []*autocliv1.PositionalArgDescriptor{{ProtoField: "add"}}},
            },
        },
    }
}

模拟

Simulation 允许 SDK 在类似模糊测试的过程中,针对模块生成随机交易。

代码位置

测试它

你可以通过仓库中的模拟测试目标来执行模拟,相关说明见运行与测试教程。 x/counter 实现了基于 simsx 的模拟,这使 SDK 的模拟框架能够在模糊测试期间生成随机的 Add 交易:
// x/counter/simulation/msg_factory.go
func MsgAddFactory() simsx.SimMsgFactoryFn[*types.MsgAddRequest] {
    return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *types.MsgAddRequest) {
        sender := testData.AnyAccount(reporter)
        if reporter.IsSkipped() {
            return nil, nil
        }

        r := testData.Rand()
        addAmount := uint64(r.Intn(100) + 1)

        msg := &types.MsgAddRequest{
            Sender: sender.AddressBech32,
            Add:    addAmount,
        }

        return []simsx.SimAccount{sender}, msg
    }
}
module.go 这样注册这个工厂:
func (a AppModule) WeightedOperationsX(weights simsx.WeightSource, reg simsx.Registry) {
    reg.Add(weights.Get("msg_add", 100), simulation.MsgAddFactory())
}

BeginBlock 与 EndBlock

这些 hooks 允许模块在每个区块开始或结束时自动运行代码。在 x/counter 中,它们被有意留空,用于展示可以在何处以及如何添加这些功能。

代码位置

app.go 变更

由于该模块声明了区块钩子,app.go 必须在两个 blocker 顺序列表中都包含 countertypes.ModuleName。

将它添加到你的模块

要为你自己的模块添加 begin 和 end blocker,需要做两处修改:
  1. 在 x/<module>/module.go 中实现这些钩子
  2. 在 app.go 中将你的模块名添加到 SetOrderBeginBlockers 和 SetOrderEndBlockers
module.go 实现了 HasBeginBlocker 和 HasEndBlocker:
func (a AppModule) BeginBlock(ctx context.Context) error {
    // optional: logic to execute at the start of every block
    return nil
}

func (a AppModule) EndBlock(ctx context.Context) error {
    // optional: logic to execute at the end of every block
    return nil
}
在 app.go 中,模块会像这样被添加到 blocker 顺序列表:
app.ModuleManager.SetOrderBeginBlockers(
    // ...
    countertypes.ModuleName,
)

app.ModuleManager.SetOrderEndBlockers(
    // ...
    countertypes.ModuleName,
)
x/counter 没有逐区块逻辑,因此两个方法都返回 nil。它们存在的目的是演示这种模式:需要逐区块执行的模块(如 staking、distribution)会在这里实现真实逻辑。例如,一个每个区块自动递增的计数器,可以在 BeginBlock 中调用 k.AddCount(ctx, 1),而不是暴露一个消息类型。

单元测试

完整模块示例包含一套真实的 测试套件,覆盖 keeper 逻辑、查询行为、消息处理和 bank keeper 交互。

代码位置

运行它们

你可以直接运行计数器模块的测试:
go test ./x/counter/...

将它添加到你的模块

从 keeper、消息服务端和查询服务端测试开始。如果你的模块依赖另一个 keeper,请使用类似 MockBankKeeper 这样的小型 mock 接口,以便你能在隔离环境中控制成功和失败场景。 x/counter 在 x/counter/keeper/ 中提供了一套完整测试:
文件测试内容
keeper_test.goKeeperTestSuite 初始化、InitGenesis、ExportGenesis、GetCount、AddCount、SetParams
msg_server_test.goMsgAdd、事件发出、MsgUpdateParams
query_server_test.goQueryCount、QueryParams
这三个文件共用在 keeper_test.go 中定义的 KeeperTestSuite 结构体,它会初始化隔离的内存存储、一个 mock bank keeper 以及一个真实的 keeper 实例:
type KeeperTestSuite struct {
    suite.Suite
    ctx         sdk.Context
    keeper      *keeper.Keeper
    queryClient types.QueryClient
    msgServer   types.MsgServer
    bankKeeper  *MockBankKeeper
    authority   string
}
MockBankKeeper 让测试无需真实 bank 模块,就能精确控制 bank keeper 的返回结果:
type MockBankKeeper struct {
    SendCoinsFromAccountToModuleFn func(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
}
测试通过设置 SendCoinsFromAccountToModuleFn 来模拟成功或失败:
s.bankKeeper.SendCoinsFromAccountToModuleFn = func(...) error {
    return errors.New("insufficient funds")
}

Gas

app.toml 中的 minimum-gas-prices 用于设置节点在接受并转发一笔交易前所要求的最低费用。make start 启动的本地开发链将该项留空,因此交易无需支付除模块参数 AddCost 之外的额外费用也会被接受。 如果要要求最低网络费用,请在 app.toml 中设置:
minimum-gas-prices = "0.025stake"
未达到最低要求的交易会在到达你的模块之前就被节点拒绝。这是节点级配置,而不是全链共识规则,因此在真实网络中,每个验证者都会分别配置自己的阈值。 下一步:运行与测试 →
If you came here from the module building tutorial, switch back to the main branch of the cosmos/example repo first:
git checkout main
The minimal counter you built in the previous tutorial captures the core SDK module pattern. The full x/counter module example in main follows the same pattern and adds several features on top. This walkthrough is meant to show you exactly what each feature is, what it does, and how you can add a similar feature to any module.

Minimal vs full counter

The full counter in the main branch adds quite a bit of functionality to the minimal tutorial counter.
Featureminimal x/counterfull x/counter
Statecountcount + params
MessagesAddAdd + UpdateParams
QueriesCountCount + Params
ValidationNoneMaxAddValue limit, overflow check
FeesNoneAddCost charged via bank module
AuthorityNoneGovernance-gated param updates
ErrorsGenericNamed sentinel errors
TelemetryNoneOpenTelemetry counter metric
CLIAutoCLIAutoCLI + EnhanceCustomCommand
SimulationNonesimsx weighted operations
Block hooksNoneBeginBlock + EndBlock
Unit testsNoneFull keeper/msg/query test suite
The wiring code in msg_server.go, query_server.go, module.go, and types/ is structurally similar between the two. Much of the new keeper logic lives in a single method: AddCount in keeper.go.

Params and authority

A module param is on-chain configuration that controls how the module behaves without changing the code. The full counter adds a Params type that lets the chain governance configure the module’s behavior at runtime. In the full module, params control how large an Add can be and how much it costs.

Where the code lives

Try it

You can inspect the current params with:
exampled query counter params

Add this to your module

To add runtime-configurable params to your own module, make these changes:
  1. Define a Params type in proto
  2. Add a privileged UpdateParams message
  3. Add a query to read the current params
  4. Store the params and authority in your keeper
  5. Check the authority in MsgServer before writing new params

state.proto

The relevant addition in state.proto is:
message Params {
  uint64 max_add_value = 1;
  repeated cosmos.base.v1beta1.Coin add_cost = 2 [
    (gogoproto.nullable) = false,
    (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins",
    (amino.dont_omitempty) = true
  ];
}
MaxAddValue caps how much a single Add call can increment the counter. AddCost sets an optional fee charged for each add operation.

tx.proto - UpdateParams

The relevant addition in tx.proto is:
rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse);

message MsgUpdateParams {
  option (cosmos.msg.v1.signer) = "authority";
  string authority = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
  Params params = 2 [(gogoproto.nullable) = false];
}

message MsgUpdateParamsResponse {}
UpdateParams is a privileged message. Only the authority address can call it. By default that address is the governance module account, so params can only be changed through a governance proposal.

query.proto - Params

query.proto adds a second query to expose the current params:
rpc Params(QueryParamsRequest) returns (QueryParamsResponse);

The authority pattern

The keeper stores the authority address and checks it on every UpdateParams call:
type Keeper struct {
    // ...
    // authority is the address capable of executing a MsgUpdateParams message.
    // Typically, this should be the x/gov module account.
    authority string
}
// msg_server.go
func (m msgServer) UpdateParams(ctx context.Context, msg *types.MsgUpdateParams) (*types.MsgUpdateParamsResponse, error) {
    if m.authority != msg.Authority {
        return nil, sdkerrors.Wrapf(govtypes.ErrInvalidSigner,
            "invalid authority; expected %s, got %s", m.authority, msg.Authority)
    }
    return &types.MsgUpdateParamsResponse{}, m.SetParams(ctx, msg.Params)
}
The authority defaults to the governance module account at keeper construction:
authority: authtypes.NewModuleAddress(govtypes.ModuleName).String(),
This pattern, storing authority in the keeper and checking it in MsgServer, is the standard Cosmos SDK approach to governance-gated configuration.

Expected keepers and fee collection

This section shows the standard Cosmos SDK pattern for module-to-module interaction. x/counter uses an expected keeper to call into the bank module and charge a fee for each add operation.

Where the code lives

app.go changes

This feature requires two app.go changes:
  • add countertypes.ModuleName: nil to maccPerms
  • pass app.BankKeeper into counterkeeper.NewKeeper(...)
In app.go, those changes look like this:
maccPerms = map[string][]string{
    // ...
    countertypes.ModuleName: nil,
}
app.CounterKeeper = counterkeeper.NewKeeper(
    runtime.NewKVStoreService(keys[countertypes.StoreKey]),
    appCodec,
    app.BankKeeper,
)

Try it

Submit an add transaction and the configured AddCost fee will be charged from the sender:
exampled tx counter add 5 --from alice --chain-id demo --yes

Add this to your module

To add fee collection through the bank module, make these changes:
  1. Define a narrow bank keeper interface in types/expected_keepers.go
  2. Add a bankKeeper field to your keeper
  3. Charge the fee inside your keeper business logic
  4. Add a module account entry in maccPerms
  5. Pass app.BankKeeper into your keeper constructor in app.go

expected_keepers.go

Rather than importing the bank module directly, the counter module defines the minimal interface it needs:
// x/counter/types/expected_keepers.go
type BankKeeper interface {
    SendCoinsFromAccountToModule(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
}
This keeps the dependency explicit and narrow. The counter module cannot accidentally call any other bank method.

Keeper struct

type Keeper struct {
    Schema     collections.Schema
    counter    collections.Item[uint64]
    params     collections.Item[types.Params]
    bankKeeper types.BankKeeper
    authority  string
}

Fee charging in AddCount

func (k *Keeper) AddCount(ctx context.Context, sender string, amount uint64) (uint64, error) {
    if amount >= math.MaxUint64 {
        return 0, ErrNumTooLarge
    }

    params, err := k.GetParams(ctx)
    if err != nil {
        return 0, err
    }

    if params.MaxAddValue > 0 && amount > params.MaxAddValue {
        return 0, ErrExceedsMaxAdd
    }

    if !params.AddCost.IsZero() {
        senderAddr, err := sdk.AccAddressFromBech32(sender)
        if err != nil {
            return 0, err
        }
        if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, senderAddr, types.ModuleName, params.AddCost); err != nil {
            return 0, sdkerrors.Wrap(ErrInsufficientFunds, err.Error())
        }
    }

    count, err := k.GetCount(ctx)
    if err != nil {
        return 0, err
    }

    newCount := count + amount
    if err := k.counter.Set(ctx, newCount); err != nil {
        return 0, err
    }

    sdkCtx := sdk.UnwrapSDKContext(ctx)
    sdkCtx.EventManager().EmitEvent(
        sdk.NewEvent(
            "count_increased",
            sdk.NewAttribute("count", fmt.Sprintf("%v", newCount)),
        ),
    )

    countMetric.Add(ctx, int64(amount))

    return newCount, nil
}
All the business logic, validation, fee charging, state mutation, events, and telemetry, lives in AddCount. The MsgServer stays thin:
func (m msgServer) Add(ctx context.Context, req *types.MsgAddRequest) (*types.MsgAddResponse, error) {
    newCount, err := m.AddCount(ctx, req.GetSender(), req.GetAdd())
    if err != nil {
        return nil, err
    }
    return &types.MsgAddResponse{UpdatedCount: newCount}, nil
}
Because AddCount is a named keeper method, it can also be called from BeginBlock, governance hooks, or other modules, not just from the MsgServer.

Module accounts

A module account is an on-chain account owned by a module instead of a user. Modules use module accounts to hold funds, receive fees, or get special permissions like minting or burning. Because x/counter receives fees from users, it needs a module account entry in app.go:
maccPerms = map[string][]string{
    // ...
    countertypes.ModuleName: nil,
}
This lives in the maccPerms map in app.go. Here, nil means the module account can receive funds but does not get extra permissions like minting or burning.

Sentinel errors

Rather than returning generic errors, x/counter defines named sentinel errors with registered codes. That makes failures easier to understand and easier for clients to match on programmatically.

Where the code lives

// keeper/errors.go
var (
    ErrNumTooLarge       = errors.Register("counter", 0, "requested integer to add is too large")
    ErrExceedsMaxAdd     = errors.Register("counter", 1, "add value exceeds max allowed")
    ErrInsufficientFunds = errors.Register("counter", 2, "insufficient funds to pay add cost")
)
Registered errors produce structured error responses on-chain that clients can match against by code, not just by string. Each error code must be unique within the module and greater than zero (code 1 is reserved for internal SDK errors). To check whether an error is of a specific sentinel type, use errors.Is(err, ErrInsufficientFunds) — this works correctly even when the error has been wrapped with additional context via errorsmod.Wrap or errorsmod.Wrapf. All validation — both stateless field checks and stateful business logic checks — should live in the msgServer method or the keeper function it calls. The older ValidateBasic method on message types is deprecated: prefer performing all validation inside the message server. If your message type does implement ValidateBasic, the SDK still calls it for backward compatibility, but new modules should not rely on it.

Telemetry

Telemetry records how often the counter is updated so you can observe module activity in an OpenTelemetry-compatible system.

Where the code lives

// x/counter/keeper/telemetry.go
var (
    meter = otel.Meter("github.com/cosmos/example/x/counter")

    countMetric metric.Int64Counter
)

func init() {
    var err error
    countMetric, err = meter.Int64Counter("count")
    if err != nil {
        panic(err)
    }
}
countMetric.Add(ctx, int64(amount)) in AddCount increments an OpenTelemetry counter every time the module state is updated. This makes module activity visible in any OTel-compatible observability system.

AutoCLI

AutoCLI exposes the module’s queries and transactions as CLI commands. The full module example keeps the same basic AutoCLI setup as the minimal module and adds the recommended setting for custom command integration.

Where the code lives

Try it

These commands come from the AutoCLI configuration. count and add are customized explicitly in autocli.go, and params is still available from the generated query service.
exampled query counter count
exampled query counter params
exampled tx counter add 5 --from alice --chain-id demo --yes
Both modules use AutoCLI. The only difference is that x/counter sets EnhanceCustomCommand: true, which merges any hand-written CLI commands with the auto-generated ones. Since neither module has hand-written commands, it is a no-op here, but it is a good default for fuller modules. The autocli.go file in x/counter:
// autocli.go
func (a AppModule) AutoCLIOptions() *autocliv1.ModuleOptions {
    return &autocliv1.ModuleOptions{
        Query: &autocliv1.ServiceCommandDescriptor{
            Service:              "example.counter.Query",
            EnhanceCustomCommand: true,
            RpcCommandOptions: []*autocliv1.RpcCommandOptions{
                {RpcMethod: "Count", Use: "count", Short: "Query the current counter value"},
            },
        },
        Tx: &autocliv1.ServiceCommandDescriptor{
            Service:              "example.counter.Msg",
            EnhanceCustomCommand: true,
            RpcCommandOptions: []*autocliv1.RpcCommandOptions{
                {RpcMethod: "Add", Use: "add [amount]", Short: "Add to the counter",
                    PositionalArgs: []*autocliv1.PositionalArgDescriptor{{ProtoField: "add"}}},
            },
        },
    }
}

Simulation

Simulation lets the SDK generate randomized transactions against the module during fuzz-style testing.

Where the code lives

Test it

You can exercise simulation through the repo’s simulation test targets described in the running and testing tutorial. x/counter implements simsx-based simulation, which lets the SDK’s simulation framework generate random Add transactions during fuzz testing:
// x/counter/simulation/msg_factory.go
func MsgAddFactory() simsx.SimMsgFactoryFn[*types.MsgAddRequest] {
    return func(ctx context.Context, testData *simsx.ChainDataSource, reporter simsx.SimulationReporter) ([]simsx.SimAccount, *types.MsgAddRequest) {
        sender := testData.AnyAccount(reporter)
        if reporter.IsSkipped() {
            return nil, nil
        }

        r := testData.Rand()
        addAmount := uint64(r.Intn(100) + 1)

        msg := &types.MsgAddRequest{
            Sender: sender.AddressBech32,
            Add:    addAmount,
        }

        return []simsx.SimAccount{sender}, msg
    }
}
module.go registers this factory:
func (a AppModule) WeightedOperationsX(weights simsx.WeightSource, reg simsx.Registry) {
    reg.Add(weights.Get("msg_add", 100), simulation.MsgAddFactory())
}

BeginBlock and EndBlock

These hooks let a module run code automatically at the start or end of every block. In x/counter, they are purposefully empty to demonstrate where and how these features can be added.

Where the code lives

  • x/counter/module.go implements BeginBlock and EndBlock
  • app.go adds the module to SetOrderBeginBlockers and SetOrderEndBlockers

app.go changes

Because the module advertises block hooks, app.go must include countertypes.ModuleName in both blocker order lists.

Add this to your module

To add begin and end blockers to your own module, make two changes:
  1. Implement the hooks in x/<module>/module.go
  2. Add your module name to SetOrderBeginBlockers and SetOrderEndBlockers in app.go
module.go implements HasBeginBlocker and HasEndBlocker:
func (a AppModule) BeginBlock(ctx context.Context) error {
    // optional: logic to execute at the start of every block
    return nil
}

func (a AppModule) EndBlock(ctx context.Context) error {
    // optional: logic to execute at the end of every block
    return nil
}
In app.go, the module is added to the blocker order lists like this:
app.ModuleManager.SetOrderBeginBlockers(
    // ...
    countertypes.ModuleName,
)

app.ModuleManager.SetOrderEndBlockers(
    // ...
    countertypes.ModuleName,
)
x/counter has no per-block logic, so both methods return nil. They exist to demonstrate the pattern: modules that need per-block execution (staking, distribution) implement real logic here. For example, a counter that auto-increments every block would call k.AddCount(ctx, 1) from BeginBlock instead of exposing a message type.

Unit tests

The full module example includes a real test suite for keeper logic, query behavior, message handling, and bank keeper interactions.

Where the code lives

Run them

You can run the counter module tests directly with:
go test ./x/counter/...

Add this to your module

Start with keeper, message server, and query server tests. If your module depends on another keeper, use a small mock interface like MockBankKeeper so you can control success and failure cases in isolation. x/counter ships a full test suite in x/counter/keeper/:
FileWhat it tests
keeper_test.goKeeperTestSuite setup, InitGenesis, ExportGenesis, GetCount, AddCount, SetParams
msg_server_test.goMsgAdd, event emission, MsgUpdateParams
query_server_test.goQueryCount, QueryParams
All three files share the KeeperTestSuite struct defined in keeper_test.go, which sets up an isolated in-memory store, a mock bank keeper, and a real keeper instance:
type KeeperTestSuite struct {
    suite.Suite
    ctx         sdk.Context
    keeper      *keeper.Keeper
    queryClient types.QueryClient
    msgServer   types.MsgServer
    bankKeeper  *MockBankKeeper
    authority   string
}
MockBankKeeper lets tests control exactly what the bank keeper returns without needing a real bank module:
type MockBankKeeper struct {
    SendCoinsFromAccountToModuleFn func(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
}
Tests set SendCoinsFromAccountToModuleFn to simulate success or failure:
s.bankKeeper.SendCoinsFromAccountToModuleFn = func(...) error {
    return errors.New("insufficient funds")
}

Gas

minimum-gas-prices in app.toml sets the minimum fee a node requires before it will accept and relay a transaction. The local dev chain started by make start leaves this empty, so transactions are accepted with no fee beyond the AddCost module parameter. To require a minimum network fee, set it in app.toml:
minimum-gas-prices = "0.025stake"
Transactions that don’t meet the minimum will be rejected by the node before they reach your module. This is a per-node setting, not a chain-wide consensus rule, so validators on a live network each configure their own threshold. Next: Running and Testing →