The “Moving From Full-Stack Developer To Web3 Pioneer” publication provided a high-level overview to give full-stack developers a glimpse into the world of Web3 development. If you haven’t had a chance to review that article, consider taking a look, since it provides a good introduction into Web3 there as well.
The end result of my original article demonstrated how a homeowners association (HOA) could use Web3 technology to host their election ballot. The problem with the original design is that the underlying smart contract only allowed for a single yes or no answer. This was by design to keep the smart contract simple, while introducing other concepts required to create an HOA ballot using Web3 technologies.
The purpose of this publication is to dive deeper into smart contracts to build an application that not only captures realistic need and function for an HOA ballot, but designs one that can be reused from one election to the next.
About Smart Contracts
Before we get started, let’s define a smart contract:
“A smart contract is a program that runs at an address on Ethereum. They're made up of data and functions that can execute upon receiving a transaction. Here's an overview of what makes up a smart contract.”
source ethereum.org
The Gumball Machine
Believe it or not, an easy example of a smart contract can be found in a simple gumball machine:
People easily understand the cost related to a purchase with the gumball machine. Normally, this is a (US) quarter. It is important to point out here that the customer is anonymous, as the gumball machine does not require knowing who a person is before giving a piece of savory gum to them.
The anonymous consumer places currency into the gumball machine and rotates the dial to accept the terms of the contract. This step is important because the transaction is transparent and peer-to-peer: between you and the machine. The transaction is also secured, since you must supply the expected currency to use the gumball machine.
Once the currency drops inside the gumball machine, the contract terms are accepted, and a gumball rolls towards the bottom of the machine, allowing the customer to receive their purchase. At this point, the contract is fully executed.
The customer must accept what is provided, meaning they cannot return the gumball or reverse the dial to get their currency back. In the very same way, smart contracts are usually irreversible and unmodifiable.
Smart Contract Use Cases
Aside from financially-driven examples, some scenarios where anonymous, trustless, decentralized, and transparent interactions that are irreversible and unmodifiable could be implemented are noted below:
- Clinical Trials - results of independent tests
- Elections - votes cast participants
- Identity - allow individuals to determine who they share their identity with
- Insurance Policies - individual policies and terms & Product and Supply Tracking - status tracking for production and supply tracking
- Real Estate and Land - deeds related to real estate and land, which can be used to derive the current owner at any point in time
- Recording Information - official records and transcripts (like the Gettysburg address)
In every case, the contents of the smart contract can be recalled and reviewed as often as possible, without the ability to change or modify the results. Each use case above provides the smart contract as the system of record for the underlying information.
What a Smart Contract is Not
At this point in time, smart contracts are not legally binding agreements, except for a few outliers. This means, if you are not satisfied with the outcome of your smart contract, taking your issue before a judge in some court system is not possible.
There are a few exceptions like in the state of Arizona where smart contracts are considered legally binding. Additionally, if you are in the state of California and your marriage license is contained within a smart contract, that agreement is legally binding as well. The expectation is that more governments will recognize smart contracts as legally-binding agreements in the future.
Use Case: Creating a Realistic HOA Ballot
Building upon the simple binary (yes/no) smart contract from the “Moving From Full-Stack Developer To Web3 Pioneer” publication, let’s take things a step forward and assume the following requirement exists for an HOA ballot for a neighborhood that has a single position to fill:
- Select the HOA President
Ideally, the goal would be for a single smart contract to be used every time there is an HOA election. Those running for the president position are expected to change from one election to the next.
Now, let’s start making a smart contract to handle our needs.
Defining Our New Smart Contract
Using Solidity, I worked with Paul McAviney, who crafted our smart contract for the HOA ballot as shown below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
/********************************************************/
/* For learning purposes ONLY. Do not use in production */
/********************************************************/
// Download into project folder with `npm install @openzeppelin/contracts`
import "@openzeppelin/contracts/access/Ownable.sol";
// Inherits the Ownable contract so we can use its functions and modifiers
contract HOABallot is Ownable {
// Custom type to describe a Presidential Candidate and hold votes
struct Candidate {
string name;
uint256 votes;
}
// Array of Presidential Candidates
Candidate[] public candidates;
// Add a President Candidate - onlyOwner
function addCandidate(string memory _name) public onlyOwner {
require(bytes(_name).length > 0, "addCandidate Error: Please enter a name");
candidates.push(Candidate({name: _name, votes: 0}));
}
// Remove a Candidate - onlyOwner
function removeCandidate(string memory _name) public onlyOwner {
require(bytes(_name).length > 0, "removeCandidate Error: Please enter a name");
bool foundCandidate = false;
uint256 index;
bytes32 nameEncoded = keccak256(abi.encodePacked(_name));
// Set index number for specific candidate
for (uint256 i = 0; i < candidates.length; i++) {
if (keccak256(abi.encodePacked(candidates[i].name)) == nameEncoded) {
index = i;
foundCandidate = true;
}
}
// Make sure a candidate was found
require(foundCandidate, "removeCandidate Error: Candidate not found");
// shift candidate to be removed to the end of the array and the rest forward
for (uint256 i = index; i < candidates.length - 1; i++) {
candidates[i] = candidates[i + 1];
}
// remove last item from array
candidates.pop();
}
// Reset the President Vote Counts - onlyOwner
function resetVoteCount() public onlyOwner {
for (uint256 p = 0; p < candidates.length; p++) {
candidates[p].votes = 0;
}
}
// Add a vote to a candidate by name
function addVoteByName(string memory _name) public {
require(bytes(_name).length > 0, "addVoteByName Error: Please enter a name");
// Encode name so only need to do once
bytes32 nameEncoded = keccak256(abi.encodePacked(_name));
for (uint256 i = 0; i < candidates.length; i++) {
// solidity can't compare strings directly, need to compare hash
if (keccak256(abi.encodePacked(candidates[i].name)) == nameEncoded) {
candidates[i].votes += 1;
}
}
}
// Returns all the Presidential Candidates and their vote counts
function getCandidates() public view returns (Candidate[] memory) {
return candidates;
}
function getWinner() public view returns (Candidate memory winner) {
uint256 winningVoteCount = 0;
for (uint256 i = 0; i < candidates.length; i++) {
if (candidates[i].votes > winningVoteCount) {
winningVoteCount = candidates[i].votes;
winner = candidates[i];
}
}
return winner;
}
}
Here are some key items related to the design of the smart contract:
- By default, there are no candidates on the ballot.
- Candidates can be added (by the smart contract owner only) using the addCandidate() function.
- Similarly, candidates can be removed (by the smart contract owner only) using the removeCandidate() function.
- Casting a vote will leverage the getCandidates() function, which can be used in the corresponding Dapp to call the addVoteByName() function.
- The same getCandidates() method can be called to determine the current vote count.
- OpenZeppelin's Ownable contract enables the ownership of the contract, as well as the ability to transfer the ownership to another address.
Now, let’s get the smart contract ready to use.
Preparing To Use the Smart Contract
In order to be able to use our smart contract, we will build a simple Truffle project and deploy the contract to the Ropsten testnet. To do this, we’ll first need the most recent version of Truffle. With NPM installed, run the command:
npm install -g truffle
Installing the latest version will give us access to the Truffle Dashboard, which will make deploying our smart contract so much easier and considerably safer, as we won’t have to share our private wallet keys or mnemonic phrases. We’ll get to that a little later though.
Next, create a new directory and initialize a new Truffle project.
mkdir hoa-ballot-contract && cd hoa-ballot-contract
truffle init
This will create a barebones smart contract project we can fill out as we see fit. So open up the project in your favorite code editor, and let’s get to it!
In order to leverage OpenZeppelin, the following command needs to be executed in the project folder as well:
npm install @openzeppelin/contracts
Open up the truffle-config.js file and we’ll add the Truffle Dashboard inside the networks
object. Aside from all the commented out boilerplate, our object should now look like this:
networks: {
dashboard: {
port: 24012,
}
}
For the next step, we’ll create a new smart contract file. Inside the contracts folder, create a new file and name it HOABallot.sol. From here, we’ll just paste in the smart contract above.
The last thing we need to do before we can deploy this contract is set up the deployment script. Using the contents below, we need to create a new file in the migrations folder called 2_hoaballot_migration.js.
const HOABallot = artifacts.require("HOABallot");
Module.exports = function (deployer) {
deployer.deploy(HOABallot);
}
Now we are ready to deploy our contract to the Ropsten testnet. In a new terminal window, type the following command to start the dashboard:
truffle dashboard
Once it’s running, our browser should pop up with an interface asking us to connect our wallet. If this doesn’t pop up for you, navigate to localhost:24012
.
Single-clicking the METAMASK button will launch MetaMask via the browser plug-in. If you don’t have a wallet browser extension installed, you can get one at metamask.io. Follow the steps to create an account and then return to the Truffle Dashboard to connect:
After entering a valid password and using the Unlock button, the Truffle Dashboard confirms the network to be used:
After clicking the CONFIRM button, the Truffle Dashboard is now listening for requests:
We will need Ropsten Eth in order to carry out the deployment. If you don’t have any, you can request some at this faucet.
All we have to do now is deploy the contract. In your original terminal window, make sure you are in the project folder and type the command:
truffle migrate --network dashboard
Truffle will automatically compile our smart contract and then route the request through the dashboard. Each request will follow the same flow listed below.
First, the Truffle Dashboard asks for confirmation to process the request:
Upon pressing the PROCESS button, the MetaMask plug-in will also ask for confirmation:
The Confirmation button will allow funds to be removed from this associated wallet in order to process each request.
When the process is complete, the following information will appear in the terminal window used to issue the truffle migrate command:
2_hoaballot_migration.js
========================
Deploying 'HOABallot'
---------------------
> transaction hash: 0x5370b6f9ee1f69e92cc6289f9cb0880386f15bff389b54ab09a966c5d144f59esage.
> Blocks: 0 Seconds: 32
> contract address: 0x2981d347e288E2A4040a3C17c7e5985422e3cAf2
> block number: 12479257
> block timestamp: 1656386400
> account: 0x7fC3EF335D16C0Fd4905d2C44f49b29BdC233C94
> balance: 41.088173901232893417
> gas used: 1639525 (0x190465)
> gas price: 2.50000001 gwei
> value sent: 0 ETH
> total cost: 0.00409881251639525 ETH
> Saving migration to chain.
> Saving artifacts
-------------------------------------
> Total cost: 0.00409881251639525 ETH
Summary
=======
> Total deployments: 1
> Final cost: 0.00409881251639525 ETH
Now, using the contract address value, we can validate the smart contract using the following URL:
https://ropsten.etherscan.io/address/0x2981d347e288E2A4040a3C17c7e5985422e3cAf2
Now we can switch over and start building the Dapp.
Creating the HOA Ballot Dapp Using React
I will create a React application called hoa-ballot-client
using the React CLI:
npx create-react-app hoa-ballot-client
Next, I changed directories into the newly-created folder and executed the following to install the web3 and OpenZepplin dependencies into the React application:
cd hoa-ballot-client
npm install web3
npm install @openzeppelin/contracts —save
Based upon the contents of the HOABallot.sol smart contract file, I navigated to the build/contracts folder and opened the HOBallot.json file, then used the values for the "abi" property for the hoaBallot constant of the abi.js file as shown below:
export const hoaBallot = [
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "candidates",
"outputs": [
{
"internalType": "string",
"name": "name",
"type": "string"
},
{
"internalType": "uint256",
"name": "votes",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "_name",
"type": "string"
}
],
"name": "addCandidate",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "_name",
"type": "string"
}
],
"name": "removeCandidate",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "resetVoteCount",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "_name",
"type": "string"
}
],
"name": "addVoteByName",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getCandidates",
"outputs": [
{
"components": [
{
"internalType": "string",
"name": "name",
"type": "string"
},
{
"internalType": "uint256",
"name": "votes",
"type": "uint256"
}
],
"internalType": "struct HOABallot.Candidate[]",
"name": "",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [],
"name": "getWinner",
"outputs": [
{
"components": [
{
"internalType": "string",
"name": "name",
"type": "string"
},
{
"internalType": "uint256",
"name": "votes",
"type": "uint256"
}
],
"internalType": "struct HOABallot.Candidate",
"name": "winner",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
}
];
This file was placed into a newly-created abi folder inside the src folder of the React application.
Now, we need to update the React Apps.js file. Let's first start with the top of the file, which needs to be configured as shown below:
import React, { useState } from "react";
import { hoaBallot } from "./abi/abi";
import Web3 from "web3";
import "./App.css";
const web3 = new Web3(Web3.givenProvider);
const contractAddress = "0x2981d347e288E2A4040a3C17c7e5985422e3cAf2";
const storageContract = new web3.eth.Contract(hoaBallot, contractAddress);
The contractAddress can be found in a number of ways. In this case, I used the results in the truffle - migrate CLI command. Another option is to use the Etherscan site.
Now, all that is left is to create standard React code to accomplish the following things:
- Add an HOA presidential candidate
- Remove an HOA presidential candidate
- Get a list of HOA presidential candidates
- Vote for an HOA presidential candidate
- Determine the HOA president
In my "Moving From Full-Stack Developer To Web3 Pioneer" publication, I added the Nav component too, so that the voter's address is displayed for easy reference.
The updated React application now appears as follows:
const web3 = new Web3(Web3.givenProvider);
const contractAddress = "0x2981d347e288E2A4040a3C17c7e5985422e3cAf2";
const storageContract = new web3.eth.Contract(hoaBallot, contractAddress);
const gasMultiplier = 1.5;
const useStyles = makeStyles((theme) => ({
root: {
"& > *": {
margin: theme.spacing(1),
},
},
}));
const StyledTableCell = styled(TableCell)(({ theme }) => ({
backgroundColor: theme.palette.common.black,
color: theme.palette.common.white,
fontSize: 14,
fontWeight: 'bold'
},
fontSize: 14
},
}));
function App() {
const classes = useStyles();
const [newCandidateName, setNewCandidateName] = useState("");
const [account, setAccount] = useState("");
const [owner, setOwner] = useState("");
const [candidates, updateCandidates] = useState([]);
const [winner, setWinner] = useState("unknown candidate");
const [waiting, setWaiting] = useState(false);
const loadAccount = async(useSpinner) => {
if (useSpinner) {
setWaiting(true);
}
const web3 = new Web3(Web3.givenProvider || "http://localhost:8080");
const accounts = await web3.eth.getAccounts();
setAccount(accounts[0]);
if (useSpinner) {
setWaiting(false);
}
}
const getOwner = async (useSpinner) => {
if (useSpinner) {
setWaiting(true);
}
const owner = await storageContract.methods.owner().call();
setOwner(owner);
if (useSpinner) {
setWaiting(false);
}
};
const getCandidates = async (useSpinner) => {
if (useSpinner) {
setWaiting(true);
}
const candidates = await storageContract.methods.getCandidates().call();
updateCandidates(candidates);
await determineWinner();
if (useSpinner) {
setWaiting(false);
}
};
const determineWinner = async () => {
const winner = await storageContract.methods.getWinner().call();
if (winner && winner.name) {
setWinner(winner.name);
} else {
setWinner("<unknown candidate>")
}
}
const vote = async (candidate) => {
setWaiting(true);
const gas = (await storageContract.methods.addVoteByName(candidate).estimateGas({
data: candidate,
from: account
})) * gasMultiplier;
let gasAsInt = gas.toFixed(0);
await storageContract.methods.addVoteByName(candidate).send({
from: account,
data: candidate,
gasAsInt,
});
await getCandidates(false);
setWaiting(false);
}
const removeCandidate = async (candidate) => {
setWaiting(true);
const gas = (await storageContract.methods.removeCandidate(candidate).estimateGas({
data: candidate,
from: account
})) * gasMultiplier;
let gasAsInt = gas.toFixed(0);
await storageContract.methods.removeCandidate(candidate).send({
from: account,
data: candidate,
gasAsInt,
});
await getCandidates(false);
setWaiting(false);
}
const addCandidate = async () => {
setWaiting(true);
const gas = (await storageContract.methods.addCandidate(newCandidateName).estimateGas({
data: newCandidateName,
from: account
})) * gasMultiplier;
let gasAsInt = gas.toFixed(0);
await storageContract.methods.addCandidate(newCandidateName).send({
from: account,
data: newCandidateName,
gasAsInt,
});
await getCandidates(false);
setWaiting(false);
}
React.useEffect(() => {
setWaiting(true);
getOwner(false).then(r => {
loadAccount(false).then(r => {
getCandidates(false).then(r => {
setWaiting(false);
});
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
return (
<div className={classes.root}>
<Nav />
<div className="main">
<div className="card">
<Typography variant="h3">
HOABallot
</Typography>
{(owner && owner.length > 0) && (
<div className="paddingBelow">
<Typography variant="caption" >
This ballot is owned by: {owner}
</Typography>
</div>
)}
{waiting && (
<div className="spinnerArea" >
<CircularProgress />
<Typography gutterBottom>
Processing Request ... please wait
</Typography>
</div>
)}
{(owner && owner.length > 0 && account && account.length > 0 && owner === account) && (
<div className="ownerActions generalPadding">
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Ballot Owner Actions
</Typography>
</Grid>
<Grid item xs={6} sm={6}>
<TextField id="newCandidateName"
value={newCandidateName}
label="Candidate Name"
variant="outlined"
onChange={event => {
const { value } = event.target;
setNewCandidateName(value);
}}
/>
</Grid>
<Grid item xs={6} sm={6}>
<Button
id="addCandidateButton"
className="button"
variant="contained"
color="primary"
type="button"
size="large"
onClick={addCandidate}>Add New Candidate</Button>
</Grid>
</Grid>
</div>
)}
<Typography variant="h5" gutterBottom className="generalPadding">
Candidates
</Typography>
{(!candidates || candidates.length === 0) && (
<div>
<div className="paddingBelow">
<Typography variant="normal">
No candidates current exist.
</Typography>
</div>
<div>
<Typography variant="normal" gutterBottom>
Ballot owner must use the <strong>ADD NEW CANDIDATE</strong> button to add candidates.
</Typography>
</div>
</div>
)}
{(candidates && candidates.length > 0) && (
<div>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="customized table">
<TableHead>
<TableRow>
<StyledTableCell>Candidate Name</StyledTableCell>
<StyledTableCell align="right">Votes</StyledTableCell>
<StyledTableCell align="center">Actions</StyledTableCell>
</TableRow>
</TableHead>
<TableBody>
{candidates.map((row) => (
<TableRow
key={row.name}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell align="right">{row.votes}</TableCell>
<TableCell align="center">
<Button
color="success"
variant="contained"
onClick={() => {
vote(row.name);
}}
>
Vote
</Button>
{(owner && owner.length > 0 && account && account.length > 0 && owner === account) &&
<Button
color="error"
variant="contained"
onClick={() => {
removeCandidate(row.name);
}}
>
Remove Candidate
</Button>
}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<div className="generalPadding">
<Typography variant="normal" gutterBottom>
{winner} is winning the election.
</Typography>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default App;
To start the React-based Dapp, the Yarn CLI can be used:
yarn start
Once compiled and validated, the application will appear on the screen, as shown below:
During the video:
- I validated that I was the contract owner, since the "Your connected address" value is an exact match for the "This ballot is owned by" value and the Ballot Owner Actions section is displayed.
- As the contract owner, I was able to see and use the ADD NEW CANDIDATE button to establish candidates for the election. I used the names Dave Brown and Steve Smith for this example.
- As the contract owner, I could have also used the REMOVE CANDIDATE button as well.
- After creating both candidates, I voted for one of the two candidates using the VOTE button on the same row as the desired candidate. I voted for Dave Brown.
- The current winner of the election is displayed below the table of candidates. In this case, it is Dave Brown.
Since deploying the smart contract, anyone can view the full history at the following URL:
https://ropsten.etherscan.io/address/0x2981d347e288E2A4040a3C17c7e5985422e3cAf2
Conclusion
Since 2021, I have been trying to live by the following mission statement, which I feel can apply to any technology professional:
“Focus your time on delivering features/functionality that extends the value of your intellectual property. Leverage frameworks, products, and services for everything else.”
J. Vester
Smart contracts provide the ability to allow two parties to enter into an agreement where the result of the contract becomes a set-in-stone official record of the transaction. The adoption of a smart contract adheres to my personal mission statement in that the underlying framework avoids reinventing the wheel when the need arises for such a contract.
At the same time, the smart contract design itself goes one step further and meets my mission statement from a reusability factor. In this example, the same HOA smart contract can be used, despite different candidates running in the current election. Here we leverage the power of the smart contract to avoid creating a new smart contract every time there is an election.
When using Etherscan to look up the conversion value of one of the transactions using Google’s ETH to USD converter, the cost per transaction was 0.24 (USD) for 0.0001348975 ETH. Ironically, that was the cost for a modest gumball out of a gumball machine when I was a kid.
If you want to learn more about smart contracts, the team at ConsenSys has provided excellent resources to help you prototype your ideas to see if smart contract adoption is a valid use case.
If you are interested in the source code for this article, you can find it at the following URLs:
https://github.com/paul-mcaviney/smart-contract-deep-dive/blob/main/HOABallot.sol
https://gitlab.com/johnjvester/hoa-ballot-contract
https://gitlab.com/johnjvester/hoa-ballot-client
Have a really great day!