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
- Start the Blockchain:
ignite chain serve
- Create a Poll:
voterd tx voter create-poll "Favorite Color" "red,blue,green" --from alice --gas auto
- Cast a Vote:
voterd tx voter cast-vote POLL_ID "blue" --from bob --gas auto
- Query Polls:
voterd query voter show-poll 0
- 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+.