Building a Simple Voting Application with Solidity

Cyrille
Coinmonks
10 min readMar 9, 2023

--

In this tutorial, we build, test, and deploy a simple voting application using Solidity, a programming language designed specifically for smart contracts.

Photo by Element5 Digital on Unsplash

Introduction

Voting is a fundamental process in any democratic society, and it is essential to ensure that the voting process is transparent, secure, and accurate. Blockchain technology has the potential to transform the way we conduct voting by providing a secure, decentralized, and tamper-proof system for recording and counting votes. Smart contracts, in particular, can be used to create self-executing voting systems that are transparent and incorruptible.

In this tutorial, we will explore how to build a simple voting application using Solidity, a programming language designed specifically for smart contracts. We will guide you through the process of creating a smart contract for a voting application, compiling, testing, and deploying the contract.

Whether you are a developer interested in learning about Solidity and smart contracts or someone who wants to understand how blockchain technology can be used for voting systems, this tutorial will provide you with hands-on experience in building a simple voting application on the blockchain. So let’s get started!

Setting up the Development Environment

To set up your development environment follow the Hardhat getting started guide. We will be using Typescript for this project.

Simple Voting Application Smart-Contract

We are building a simple voting application using the Solidity programming language. This application will allow any to create a ballot with a set of options or candidates, specify a start and end time for voting, and allow users to cast their votes. Once the voting period is over, the application will tally the votes and declare the winner(s) based on the election rules.

The goal of this application is to showcase the use of Solidity in building decentralized applications with transparent and secure voting mechanisms. With this application, we can demonstrate how Solidity can be used to create smart contracts that can ensure the integrity of voting processes while promoting transparency and trust.

Key Actions

  1. Creating a ballot: The application should allow any user to create a new ballot with a specific set of options, and a start and duration for voting.
  2. Voting: Any user should be able to vote on the options listed on the ballot. The application should prevent users from voting multiple times and ensure that votes are cast only during the specified voting window.
  3. Tallying votes: Once the voting period is over, the application should tally the votes and declare the winner(s) based on the election rules.

Test Cases — Creating a ballot

We will write four test cases for this part.

  1. Should create a ballot
  2. Should revert if the ballot has less than 2 options
  3. Should revert if the start time is less than the current time
  4. Should revert if the duration is less than 1

Let’s create a file: test/SimpleVoting.ts where we will describe all our test cases. The barebone structure will consist of a deploy method, which will be used in every test.

import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"
import { expect } from "chai"
import { BigNumber } from "ethers"
import { ethers } from "hardhat"

describe("SimpleVoting", function () {

async function deploy() {
const Contract = await ethers.getContractFactory("SimpleVoting")
const contract = await Contract.deploy()
await contract.deployed()
return { contract }
}

})

Let’s write placeholders for our first 4 test cases:

describe("Creating a ballot", function () {
it("should create a ballot")
it("should revert if the ballot has less than 2 options")
it("should revert if the start time is less than the current time")
it("should revert if the end time is less than or equal to the start time")
})

Run the test command:

npx hardhat test

You will get the following:

SimpleVoting
Creating a ballot
- should create a ballot
- should revert if the ballot has less than 2 options
- should revert if the start time is less than the current time
- should revert if the end time is less than or equal to the start time


0 passing (2ms)
4 pending

This is because we haven’t actually written our test cases. Let’s write our first one.

it("should create a ballot", async function () {
const { contract } = await loadFixture(deploy)
const startTime = await time.latest() + 60 // start the ballot in 60 seconds
const duration = 300 // the ballot will be open for 300 seconds
const question = "Who is the greatest rapper of all time?"
const options = [
"Tupac Shakur",
"The Notorious B.I.G.",
"Eminem",
"Jay-Z"
]
await contract.createBallot(
question, options, startTime, duration
)
expect(await contract.getBallotByIndex(0)).to.deep.eq([
question,
options,
BigNumber.from(startTime), // convert from uint
BigNumber.from(duration), // convert from uint
])
})

Now if we run the test command, we get the following error:

HardhatError: HH700: Artifact for contract "SimpleVoting" not found. 

That is because we didn’t create a contract yet. Let’s do it.

Our basic contract will look like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

contract SimpleVoting {

}

Now we need to define our methods and data structures.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

contract SimpleVoting {
// counter enables us to use a mapping
// instead of an array for the ballots
// this is more gas effiecient
uint public counter = 0;

// the structure of a ballot object
struct Ballot {
string question;
string[] options;
uint startTime;
uint duration;
}

mapping(uint => Ballot) private _ballots;

function createBallot(
string memory question_,
string[] memory options_,
uint startTime_,
uint duration_
) external {
_ballots[counter] = Ballot(question_, options_, startTime_, duration_);
counter++;
}

function getBallotByIndex(uint index_) external view returns (Ballot memory ballot) {
ballot = _ballots[index_];
}
}

Now, if we run the tests, we get the following, first test passing!

SimpleVoting
Creating a ballot
✔ should create a ballot (646ms)
- should revert if the ballot has less than 2 options
- should revert if the start time is less than the current time
- should revert if the duration is less than 1


1 passing (650ms)
3 pending

For the next couple of tests, we testing validations.

it("should revert if the ballot has less than 2 options", async function () {
const { contract } = await loadFixture(deploy)
const startTime = await time.latest() + 60 // start the ballot in 60 seconds
const duration = 300 // the ballot will be open for 300 seconds
const question = "Who is the greatest rapper of all time?"
const options = [
"Tupac Shakur",
// "The Notorious B.I.G.",
// "Eminem",
// "Jay-Z"
]
await expect(contract.createBallot(
question, options, startTime, duration
)).to.be.revertedWith("Provide at minimum two options")
})

Let’s modify our contracts createBallot function:

function createBallot(
string memory question_,
string[] memory options_,
uint startTime_,
uint duration_
) external {
require(options_.length >= 2, "Provide at minimum two options"); // new
_ballots[counter] = Ballot(question_, options_, startTime_, duration_);
counter++;
}

Run the tests, and you should get the following:

SimpleVoting
Creating a ballot
✔ should create a ballot (685ms)
✔ should revert if the ballot has less than 2 options
- should revert if the start time is less than the current time
- should revert if the duration is less than 1


2 passing (712ms)
2 pending

Next, let’s validate the start time. Here is our test case, we changed the start time declaration.

it("should revert if the start time is less than the current time", async function () {
const { contract } = await loadFixture(deploy)
const startTime = await time.latest() - 60 // start the ballot 60 seconds before the current time
const duration = 300 // the ballot will be open for 300 seconds
const question = "Who is the greatest rapper of all time?"
const options = [
"Tupac Shakur",
"The Notorious B.I.G.",
"Eminem",
"Jay-Z"
]
await expect(contract.createBallot(
question, options, startTime, duration
)).to.be.revertedWith("Start time must be in the future")
})

Let’s modify the createBallot function:

function createBallot(
string memory question_,
string[] memory options_,
uint startTime_,
uint duration_
) external {
require(startTime_ > block.timestamp, "Start time must be in the future"); // new
require(options_.length >= 2, "Provide at minimum two options");
_ballots[counter] = Ballot(question_, options_, startTime_, duration_);
counter++;
}

Run the tests…

SimpleVoting
Creating a ballot
✔ should create a ballot (737ms)
✔ should revert if the ballot has less than 2 options
✔ should revert if the start time is less than the current time
- should revert if the duration is less than 1


3 passing (786ms)
1 pending

The last test in the create ballot section is checking the duration.

it("should revert if the duration is less than 1", async function () {
const { contract } = await loadFixture(deploy)
const startTime = await time.latest() + 60 // start the ballot in 60 seconds
const duration = 0 // the ballot will never be open
const question = "Who is the greatest rapper of all time?"
const options = [
"Tupac Shakur",
"The Notorious B.I.G.",
"Eminem",
"Jay-Z"
]
await expect(contract.createBallot(
question, options, startTime, duration
)).to.be.revertedWith("Duration must be greater than 0")
})

Let’s update the createBallot function in the contract:

function createBallot(
string memory question_,
string[] memory options_,
uint startTime_,
uint duration_
) external {
require(duration_ > 0, "Duration must be greater than 0"); // new
require(startTime_ > block.timestamp, "Start time must be in the future");
require(options_.length >= 2, "Provide at minimum two options");
_ballots[counter] = Ballot(question_, options_, startTime_, duration_);
counter++;
}

Test Cases — Casting a vote

We have four test cases for voting:

  1. Should be able to vote
  2. Should revert if the user tries to vote before the start time
  3. Should revert if the user tries to vote after the end time
  4. Should revert if the user tries to vote multiple times
describe("Casting a vote", function () {
let contract;
const duration = 300 // the ballot will be open for 300 seconds

beforeEach(async function () {
const fixture = { contract } = await loadFixture(deploy)
const startTime = await time.latest() + 60 // start the ballot in 60 seconds
const question = "Who is the greatest rapper of all time?"
const options = [ "Tupac Shakur", "The Notorious B.I.G.", "Eminem", "Jay-Z" ]
await contract.createBallot(
question, options, startTime, duration
)
})
it("should be able to vote")
it("should revert if the user tries to vote before the start time")
it("should revert if the user tries to vote after the end time")
it("should revert if the user tries to vote multiple times")
})

Test #1

it("should be able to vote", async function () {
const [signer] = await ethers.getSigners()
await time.increase(61) // make sure its ballot is open
await contract.cast(0, 0)
expect(await contract.hasVoted(0, signer.address)).to.eq(true)
expect(await contract.getTally(0,0)).to.eq(1)
})

Contract

// ...

mapping(uint => mapping(uint => uint)) private _tally;
mapping(uint => mapping(address => bool)) public hasVoted;

// ...

function cast(uint ballotIndex_, uint optionIndex_) external {
_tally[ballotIndex_][optionIndex_]++;
hasVoted[ballotIndex_][msg.sender] = true;
}

function getTally(uint ballotIndex_, uint optionIndex_) external view returns (uint) {
return _tally[ballotIndex_][optionIndex_];
}

Test #2

it("should revert if the user tries to vote before the start time", async function () {
await expect(contract.cast(0, 0)).to.be.revertedWith("Can't cast before start time")
})

Contract

function cast(uint ballotIndex_, uint optionIndex_) external {
Ballot memory ballot = _ballots[ballotIndex_]; // new
require(block.timestamp >= ballot.startTime, "Can't cast before start time"); // new
_tally[ballotIndex_][optionIndex_]++;
hasVoted[ballotIndex_][msg.sender] = true;
}

Test #3

it("should revert if the user tries to vote after the end time", async function () {
await time.increase(2000)
await expect(contract.cast(0, 0)).to.be.revertedWith("Can't cast after end time")
})

Contract

function cast(uint ballotIndex_, uint optionIndex_) external {
Ballot memory ballot = _ballots[ballotIndex_];
require(block.timestamp >= ballot.startTime, "Can't cast before start time");
require(block.timestamp < ballot.startTime + ballot.duration, "Can't cast after end time"); // new
_tally[ballotIndex_][optionIndex_]++;
hasVoted[ballotIndex_][msg.sender] = true;
}

Test #4

it("should revert if the user tries to vote multiple times", async function () {
await time.increase(61) // make sure its ballot is open
await contract.cast(0, 0)
await expect(contract.cast(0,1)).to.be.revertedWith("Address already casted a vote for ballot")
})

Contract

function cast(uint ballotIndex_, uint optionIndex_) external {
require(!hasVoted[ballotIndex_][msg.sender], "Address already casted a vote for ballot"); // new
Ballot memory ballot = _ballots[ballotIndex_];
require(block.timestamp >= ballot.startTime, "Can't cast before start time");
require(block.timestamp < ballot.startTime + ballot.duration, "Can't cast after end time");
_tally[ballotIndex_][optionIndex_]++;
hasVoted[ballotIndex_][msg.sender] = true;
}

Run our tests and we get the following:

SimpleVoting
Creating a ballot
✔ should create a ballot (709ms)
✔ should revert if the ballot has less than 2 options
✔ should revert if the start time is less than the current time
✔ should revert if the duration is less than 1
Casting a vote
✔ should be able to vote
✔ should revert if the user tries to vote before the start time
✔ should revert if the user tries to vote after the end time
✔ should revert if the user tries to vote multiple times


8 passing (993ms)

Test Cases — Tallying votes

  1. Should return the results for every option
  2. Should return the winner for a ballot
  3. Should return multiple winners for a tied ballot
describe("Tallying votes", function () {
let contract: Contract;
const duration = 300 // the ballot will be open for 300 seconds

beforeEach(async function () {
const fixture = { contract } = await loadFixture(deploy)
const startTime = await time.latest() + 60 // start the ballot in 60 seconds
const question = "Who is the greatest rapper of all time?"
const options = [
"Tupac Shakur",
"The Notorious B.I.G.",
"Eminem",
"Jay-Z"
]
await contract.createBallot(
question, options, startTime, duration
)
await time.increase(200)
const signers = await ethers.getSigners()
await contract.cast(0,0)
await contract.connect(signers[1]).cast(0,0)
await contract.connect(signers[2]).cast(0,1)
await contract.connect(signers[3]).cast(0,2)
})

it("should return the results for every option")
it("should return the winner for a ballot")
it("should return multiple winners for a tied ballot")
})

Test #1

it("should return the results for every option", async function () {
await time.increase(2000)
expect(await contract.results(0)).to.deep.eq([
BigNumber.from(2),
BigNumber.from(1),
BigNumber.from(1),
BigNumber.from(0),
])
})

Contract

function results(uint ballotIndex_) external view returns (uint[] memory) {
Ballot memory ballot = _ballots[ballotIndex_];
uint len = ballot.options.length;
uint[] memory result = new uint[](len);
for (uint i = 0; i < len; i++) {
result[i] = _tally[ballotIndex_][i];
}
return result;
}

Test #2

it("should return the winner for a ballot", async function () {
await time.increase(2000)
expect(await contract.winners(0)).to.deep.eq([true, false, false, false])
})

Contract

function winners(uint ballotIndex_) external view returns (bool[] memory) {
Ballot memory ballot = _ballots[ballotIndex_];
uint len = ballot.options.length;
uint[] memory result = new uint[](len);
uint max;
for (uint i = 0; i < len; i++) {
result[i] = _tally[ballotIndex_][i];
if (result[i] > max) {
max = result[i];
}
}
bool[] memory winner = new bool[](len);
for (uint i = 0; i < len; i++) {
if (result[i] == max) {
winner[i] = true;
}
}
return winner;
}

Test #3

it("should return multiple winners for a tied ballot", async function () {
const signers = await ethers.getSigners()
await contract.connect(signers[4]).cast(0, 2)
await time.increase(2000)
expect(await contract.winners(0)).to.deep.eq([true, false, true, false])
})

No need to change the contract code. That's it for this tutorial, there are some improvement suggestions below. The code can be found on GitHub.

Improvement Suggestions

  1. return the number of ballots
  2. emit CreateBallot event with the sender address + ballot number
  3. add methods for active and expired ballots

I’m a multidisciplinary founder, project manager, solidity + react developer, husband, and family+friends cook.

Got a project idea? Reach out to me on Twitter.

New to trading? Try crypto trading bots or copy trading on best crypto exchanges

Join Coinmonks Telegram Channel and Youtube Channel get daily Crypto News

Also, Read

--

--

Cyrille
Coinmonks

I build web2 and web3 products and teams. Depending on the project, I operate as a CTO, product manager, developer, advisor, or investor.