Tutorial: Build a Vote Module

Tutorial: Build a Vote Module

Create a vote functionality with Ignite

In this tutorial we will build a module that supports Polls to vote on.

You will learn how to

  • Create a simple blockchain poll application
  • Add logic to require funds to execute the create poll transaction
  • Modify a message

Requirements

This tutorial requires Ignite CLI v28.7.

To install Ignite CLI, follow the installation instructions on the official documentation.

Voting App Goals

Create a blockchain poll app with a voting module. The app will allow users to:

  • Create polls
  • Cast votes
  • See voting results

Additional features:

  • Collect transaction fees:
    • Create poll transaction fee is 200 tokens
    • Voting is free
  • Restrict to one vote per user per poll

Build your Blockchain App

Create a new blockchain

ignite scaffold chain voter --no-module

A new directory named voter is created containing a working blockchain app.

Change your working directory to the blockchain with

cd voter

Create the voter module

In order for the module to have the dependency to account and bank, we scaffold the module with the dependencies.

ignite scaffold module voter --dep bank,account

Add the Poll Type

Create the poll type with title and options:

ignite scaffold type poll title options:array.string --no-message

This creates the basic structure for polls but we need to modify it to handle multiple options.

Modify the Protocol Buffer Types

For running Polls modify the Protocol Buffer in the proto directory.

We need to add two fields and make sure the "options" for the Poll input is a repeated type, so each Poll can have multiple Options.

Update proto/voter/poll.proto as follows:

message Poll {
  string creator = 1;
  uint64 id = 2;
  string title = 3;
  repeated string options = 4; 
}

Add Messages for Poll Operations

Create messages for poll operations:

ignite scaffold message create-poll title options:array.string --response id:int,title --desc "Create a new poll" --module voter

Add the Vote Type

Create the vote type:

ignite scaffold type vote pollID option --no-message

Add message for creating votes:

ignite scaffold message cast-vote pollID option --response id:int,option --desc "Cast a vote on a poll" --module voter

Update the Protocol Buffer for the Vote file proto/voter/voter/vote.proto

message Vote {
  string creator = 1;
  uint64 id = 2;
  uint64 pollID = 3;
  string option = 4;
}

Add Keys

We'll need to define the Keys where the Keeper stores the information, let's define the paths in x/voter/types/keys.go in the const field.

    // Key prefixes
	PollKey      = "Poll/value/"
	PollCountKey = "Poll/count/"
	VoteKey      = "Vote/value/"
	VoteCountKey = "Vote/count/"

Implement Poll and Vote Keeper Helper Functions

Let's create a new file in the keeper directory. The x/voter/keeper/poll.go file:

package keeper

import (
    "context"
    "encoding/binary"

    "voter/x/voter/types"

    "cosmossdk.io/store/prefix"
    "github.com/cosmos/cosmos-sdk/runtime"
)

func (k Keeper) AppendPoll(ctx context.Context, poll types.Poll) uint64 {
    count := k.GetPollCount(ctx)
    poll.Id = count
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.PollKey))
    appendedValue := k.cdc.MustMarshal(&poll)
    store.Set(GetPollIDBytes(poll.Id), appendedValue)
    k.SetPollCount(ctx, count+1)
    return count
}

func (k Keeper) GetPollCount(ctx context.Context) uint64 {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, []byte{})
    byteKey := types.KeyPrefix(types.PollCountKey)
    bz := store.Get(byteKey)
    if bz == nil {
        return 0
    }
    return binary.BigEndian.Uint64(bz)
}

func GetPollIDBytes(id uint64) []byte {
    bz := make([]byte, 8)
    binary.BigEndian.PutUint64(bz, id)
    return bz
}

func (k Keeper) SetPollCount(ctx context.Context, count uint64) {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, []byte{})
    byteKey := types.KeyPrefix(types.PollCountKey)
    bz := make([]byte, 8)
    binary.BigEndian.PutUint64(bz, count)
    store.Set(byteKey, bz)
}

func (k Keeper) GetPoll(ctx context.Context, id uint64) (val types.Poll, found bool) {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.PollKey))
    b := store.Get(GetPollIDBytes(id))
    if b == nil {
        return val, false
    }
    k.cdc.MustUnmarshal(b, &val)
    return val, true
}

func (k Keeper) GetAllPolls(ctx context.Context) (list []types.Poll) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.PollKey))
	iterator := store.Iterator(nil, nil)
	defer iterator.Close()

	for ; iterator.Valid(); iterator.Next() {
		var val types.Poll
		k.cdc.MustUnmarshal(iterator.Value(), &val)
		list = append(list, val)
	}

	return
}

We will need the same for votes, let's create the x/voter/keeper/vote.go file:

package keeper

import (
    "context"
    "encoding/binary"

    "voter/x/voter/types"

    "cosmossdk.io/store/prefix"
    "github.com/cosmos/cosmos-sdk/runtime"
    errorsmod "cosmossdk.io/errors"
    sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)

func (k Keeper) CastVote(ctx context.Context, vote types.Vote) error {
    // Check if poll exists
    _, found := k.GetPoll(ctx, vote.PollID)
    if !found {
        return errorsmod.Wrap(sdkerrors.ErrKeyNotFound, "poll not found")
    }

    // Check if user has already voted
    votes := k.GetAllVote(ctx)
    for _, existingVote := range votes {
        if existingVote.Creator == vote.Creator && existingVote.PollID == vote.PollID {
            return errorsmod.Wrap(sdkerrors.ErrUnauthorized, "already voted on this poll")
        }
    }

    count := k.GetVoteCount(ctx)
    vote.Id = count
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.VoteKey))
    appendedValue := k.cdc.MustMarshal(&vote)
    store.Set(GetVoteIDBytes(vote.Id), appendedValue)
    k.SetVoteCount(ctx, count+1)

    return nil
}

func (k Keeper) GetVoteCount(ctx context.Context) uint64 {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, []byte{})
    byteKey := types.KeyPrefix(types.VoteCountKey)
    bz := store.Get(byteKey)
    if bz == nil {
        return 0
    }
    return binary.BigEndian.Uint64(bz)
}

func GetVoteIDBytes(id uint64) []byte {
    bz := make([]byte, 8)
    binary.BigEndian.PutUint64(bz, id)
    return bz
}

func (k Keeper) SetVoteCount(ctx context.Context, count uint64) {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, []byte{})
    byteKey := types.KeyPrefix(types.VoteCountKey)
    bz := make([]byte, 8)
    binary.BigEndian.PutUint64(bz, count)
    store.Set(byteKey, bz)
}

func (k Keeper) GetAllVote(ctx context.Context) (list []types.Vote) {
    storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
    store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.VoteKey))
    iterator := store.Iterator(nil, nil)
    defer iterator.Close()

    for ; iterator.Valid(); iterator.Next() {
        var val types.Vote
        k.cdc.MustUnmarshal(iterator.Value(), &val)
        list = append(list, val)
    }

    return
}

func (k Keeper) GetPollVotes(ctx context.Context, pollID uint64) (list []types.Vote) {
	allVotes := k.GetAllVote(ctx)
	for _, vote := range allVotes {
		if vote.PollID == pollID {
			list = append(list, vote)
		}
	}
	return
}

Implement Poll Creation Logic

Update the keeper method in x/voter/keeper/msg_server_create_poll.go:

package keeper

import (
   "context"

	"voter/x/voter/types"

	errorsmod "cosmossdk.io/errors"
	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)

func (k msgServer) CreatePoll(goCtx context.Context, msg *types.MsgCreatePoll) (*types.MsgCreatePollResponse, error) {
    ctx := sdk.UnwrapSDKContext(goCtx)

    // Get the module account address
    moduleAcct := k.accountKeeper.GetModuleAddress(types.ModuleName)
    if moduleAcct == nil {
        return nil, errorsmod.Wrap(sdkerrors.ErrUnknownAddress, "module account does not exist")
    }

    // Parse and validate the payment
    feeCoins, err := sdk.ParseCoinsNormalized("200token")
    if err != nil {
        return nil, errorsmod.Wrap(sdkerrors.ErrInvalidCoins, "invalid fee amount")
    }

    // Get creator's address
    creator, err := sdk.AccAddressFromBech32(msg.Creator)
    if err != nil {
        return nil, errorsmod.Wrap(sdkerrors.ErrInvalidAddress, "invalid creator address")
    }

    // Check if creator has enough balance
    spendableCoins := k.bankKeeper.SpendableCoins(ctx, creator)
    if !spendableCoins.IsAllGTE(feeCoins) {
        return nil, errorsmod.Wrap(sdkerrors.ErrInsufficientFunds, "insufficient funds to pay for poll creation")
    }

    // Transfer the fee
    if err := k.bankKeeper.SendCoins(ctx, creator, moduleAcct, feeCoins); err != nil {
        return nil, errorsmod.Wrap(err, "failed to pay poll creation fee")
    }

    // Validate poll options
    if len(msg.Options) < 2 {
        return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "poll must have at least 2 options")
    }

    poll := types.Poll{
        Creator: msg.Creator,
        Title:   msg.Title,
        Options: msg.Options,
    }

    id := k.AppendPoll(ctx, poll)
    return &types.MsgCreatePollResponse{
        Id: int32(id),
        Title: msg.Title,
    }, nil
}

Update the expected Keepers in x/voter/types/expected_keepers to make the functions for the Module Account available:

// AccountKeeper defines the expected interface for the Account module.
type AccountKeeper interface {
	GetAccount(context.Context, sdk.AccAddress) sdk.AccountI // only used for simulation
	// Methods imported from account should be defined here
	GetModuleAddress(moduleName string) sdk.AccAddress
}

// BankKeeper defines the expected interface for the Bank module.
type BankKeeper interface {
	SpendableCoins(ctx context.Context, addr sdk.AccAddress) sdk.Coins
	// Methods imported from bank should be defined here
	SendCoins(ctx context.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error
}

Implement Vote Cast Logic

Update the keeper method in x/voter/keeper/msg_server_cast_vote.go:

package keeper

import (
	"context"

	"voter/x/voter/types"

	sdk "github.com/cosmos/cosmos-sdk/types"
)

func (k msgServer) CastVote(goCtx context.Context, msg *types.MsgCastVote) (*types.MsgCastVoteResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	vote := types.Vote{
		Creator: msg.Creator,
		PollID:  msg.PollId,
		Option:  msg.Option,
	}

	err := k.Keeper.CastVote(ctx, vote)
	if err != nil {
		return nil, err
	}

		return &types.MsgCastVoteResponse{
		Id:     int32(pollId),
		Option: msg.Option,
	}, nil
}

Add Queries

After implementing the message handling for creating polls and casting votes, we need to add queries to retrieve the data.
Let's scaffold these queries using Ignite CLI.

Query Single Poll

ignite scaffold query show-poll poll-id:uint --response creator,id,title,options

Add to x/voter/keeper/query_show_poll.go

func (k Keeper) ShowPoll(goCtx context.Context, req *types.QueryShowPollRequest) (*types.QueryShowPollResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}

	ctx := sdk.UnwrapSDKContext(goCtx)
	poll, found := k.GetPoll(ctx, req.PollId)
	if !found {
		return nil, status.Error(codes.NotFound, "poll not found")
	}

	return &types.QueryShowPollResponse{
		Creator: poll.Creator,
		Id:      strconv.FormatUint(poll.Id, 10),
		Title:   poll.Title,
		Options: strings.Join(poll.Options, ","),
	}, nil
}

Query Votes For A Poll

ignite scaffold query show-poll-votes poll-id:uint --response creator,pollID,option

Add to x/voter/keeper/query_show_poll_votes.go

package keeper

import (
	"context"

	"voter/x/voter/types"

	"cosmossdk.io/store/prefix"
	"github.com/cosmos/cosmos-sdk/runtime"
	"github.com/cosmos/cosmos-sdk/types/query"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func (k Keeper) ShowPollVotes(ctx context.Context, req *types.QueryShowPollVotesRequest) (*types.QueryShowPollVotesResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}

	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.KeyPrefix(types.VoteKey))

	var votes []*types.Vote
	pageRes, err := query.Paginate(store, req.Pagination, func(key []byte, value []byte) error {
		var vote types.Vote
		if err := k.cdc.Unmarshal(value, &vote); err != nil {
			return err
		}
		// Only include votes for the requested poll
		if vote.PollID == req.PollId {
			votes = append(votes, &vote)
		}
		return nil
	})

	if err != nil {
		return nil, status.Error(codes.Internal, err.Error())
	}

	return &types.QueryShowPollVotesResponse{
		Votes:      votes,
		Pagination: pageRes,
	}, nil
}

For this to work we need to look into proto/voter/voter/query.pro with the following changes

import "voter/voter/vote.proto";

// [...]

message QueryShowPollVotesRequest {
  uint64 pollId = 1;
  // pagination defines an optional pagination for the request.
    cosmos.base.query.v1beta1.PageRequest pagination = 2;
}

message QueryShowPollVotesResponse {
  repeated Vote votes = 1;
  // Pagination defines the pagination in the response.
    cosmos.base.query.v1beta1.PageResponse pagination = 2;
}

Testing the Application

  1. Start the Blockchain:
ignite chain serve
  1. Create a Poll:
voterd tx voter create-poll "Favorite Color" "red,blue,green" --from alice --gas auto
  1. Cast a Vote:
voterd tx voter cast-vote POLL_ID "blue" --from bob --gas auto
  1. Query Polls:
voterd query voter show-poll 0
  1. Query Votes:
voterd query voter show-poll-votes 0

Congratulations! You've built a blockchain polling application with Ignite CLI and Cosmos SDK v0.50+.