Skip to main content
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.

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 →