The “ ” 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 to Web3 there as well. Moving From Full-Stack Developer To Web3 Pioneer 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 needs and functions 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 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 , who crafted our for the HOA ballot as shown below: Paul McAviney smart contract // 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 , run the command: NPM installed npm install -g truffle Installing the latest version will give us access to the , 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. Truffle Dashboard 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 file and we’ll add the Truffle Dashboard inside the object. Aside from all the commented-out boilerplate, our object should now look like this: truffle-config.js networks networks: { dashboard: { port: 24012, } } For the next step, we’ll create a new smart contract file. Inside the folder, create a new file and name it . From here, we’ll just paste in the smart contract above. contracts HOABallot.sol 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 folder called . migrations 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 button will launch MetaMask via the browser plug-in. If you don’t have a wallet browser extension installed, you can get one at . Follow the steps to create an account and then return to the Truffle Dashboard to connect: METAMASK metamask.io After entering a valid password and using the button, the Truffle Dashboard confirms the network to be used: Unlock After clicking the button, the Truffle Dashboard is now listening for requests: CONFIRM 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 button, the MetaMask plug-in will also ask for confirmation: PROCESS The button will allow funds to be removed from this associated wallet in order to process each request. Confirmation 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 value, we can validate the smart contract using the following URL: contract address 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 using the React CLI: hoa-ballot-client 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 smart contract file, I navigated to the build/contracts folder and opened the file, then used the values for the “abi” property for the constant of the file as shown below: HOABallot.sol HOBallot.json hoaBallot abi.js 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 file. Let’s first start with the top of the file, which needs to be configured as shown below: Apps.js 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 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. contractAddress 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 }) => ({ [`&.${tableCellClasses.head}`]: { backgroundColor: theme.palette.common.black, color: theme.palette.common.white, fontSize: 14, fontWeight: 'bold' }, [`&.${tableCellClasses.body}`]: { 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> &nbsp; {(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: https://youtu.be/r5mROTY8efw 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 has provided excellent resources to help you prototype your ideas to see if smart contract adoption is a valid use case. ConsenSys 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!