DEV Community

Cover image for 🗳️ Day 15 of #30DaysOfSolidity — Building a Gas-Efficient Voting System
Saurav Kumar
Saurav Kumar

Posted on

🗳️ Day 15 of #30DaysOfSolidity — Building a Gas-Efficient Voting System

“Every byte of storage costs gas. Every optimization saves ETH.”
Let’s build a voting machine that’s accurate, transparent, and cheap to run.


🧠 What You’ll Learn

  • How to create a secure and gas-optimized voting contract
  • The difference between storage, memory, and calldata
  • Why bytes32 and packed integers matter
  • How to use Foundry for development and gas reporting
  • How to integrate with Ethers.js and React for off-chain signed votes

🧩 File Structure

day-15-voting/
├── foundry.toml
├── script/
│   └── Deploy.s.sol
├── src/
│   ├── Voting.sol
│   ├── VotingString.sol
│   └── VotingEIP712.sol
└── test/
    └── Voting.t.sol
Enter fullscreen mode Exit fullscreen mode

⚙️ Step 1: Setup the Project

Install Foundry:

curl -L https://foundry.paradigm.xyz | bash
foundryup
Enter fullscreen mode Exit fullscreen mode

Initialize the project:

forge init day-15-voting
cd day-15-voting
Enter fullscreen mode Exit fullscreen mode

🧱 Step 2: The Gas-Optimized Voting Contract

Let’s start with src/Voting.sol — a minimal and efficient voting system.

🧾 Code

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

contract Voting {
    struct Proposal {
        bytes32 name; 
        uint32 voteCount;
    }

    address public immutable owner;
    uint32 public immutable startAt;
    uint32 public immutable endAt;
    Proposal[] public proposals;
    mapping(address => uint256) public ballot; 

    event VoteCasted(address indexed voter, uint256 proposalId);

    constructor(bytes32[] memory proposalNames, uint32 durationSeconds) {
        owner = msg.sender;
        startAt = uint32(block.timestamp);
        endAt = startAt + durationSeconds;

        for (uint256 i; i < proposalNames.length; ) {
            proposals.push(Proposal({name: proposalNames[i], voteCount: 0}));
            unchecked { ++i; }
        }
    }

    function vote(uint256 proposalId) external {
        require(block.timestamp < endAt, "Voting ended");
        require(ballot[msg.sender] == 0, "Already voted");
        require(proposalId < proposals.length, "Invalid proposal");

        ballot[msg.sender] = proposalId + 1;
        unchecked { proposals[proposalId].voteCount++; }

        emit VoteCasted(msg.sender, proposalId);
    }

    function winningProposal() external view returns (uint256 winnerId) {
        uint256 highestVotes = 0;
        for (uint256 i; i < proposals.length; ) {
            if (proposals[i].voteCount > highestVotes) {
                highestVotes = proposals[i].voteCount;
                winnerId = i;
            }
            unchecked { ++i; }
        }
    }

    function getProposalName(uint256 id) external view returns (bytes32) {
        return proposals[id].name;
    }
}
Enter fullscreen mode Exit fullscreen mode

⚡ Code Explanation

Concept Optimization Benefit
bytes32 name Fixed size proposal names Saves gas vs string
immutable vars Stored in bytecode Cheaper to read
unchecked loops Removes overflow checks Faster increment
Single mapping ballot Compact voter storage Reduces SSTORE ops
external function Cheaper ABI decoding Lower call gas

Storage cost matters most — so we minimize the number of SSTOREs.

Each voter only consumes one storage slot.


🧪 Step 3: Testing with Foundry

Create a test file test/Voting.t.sol:

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

import "forge-std/Test.sol";
import "../src/Voting.sol";

contract VotingTest is Test {
    Voting voting;

    function setUp() public {
        bytes32 ;
        names[0] = "Yes";
        names[1] = "No";
        voting = new Voting(names, 3600);
    }

    function testVote() public {
        vm.prank(address(0x1));
        voting.vote(0);
        assertEq(voting.ballot(address(0x1)), 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the test and see gas usage:

forge test --gas-report
Enter fullscreen mode Exit fullscreen mode

🪶 Step 4: Deploy with Foundry Script

script/Deploy.s.sol:

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

import "forge-std/Script.sol";
import "../src/Voting.sol";

contract DeployScript is Script {
    function run() external {
        vm.startBroadcast();

        bytes32 ;
        proposals[0] = "Option1";
        proposals[1] = "Option2";
        proposals[2] = "Option3";

        new Voting(proposals, 3600);

        vm.stopBroadcast();
    }
}
Enter fullscreen mode Exit fullscreen mode

Deploy locally:

forge script script/Deploy.s.sol:DeployScript --rpc-url http://127.0.0.1:8545 --broadcast
Enter fullscreen mode Exit fullscreen mode

🔐 Step 5: Off-Chain Signed Voting (EIP-712)

Let’s make voting cheaper with off-chain signatures.

src/VotingEIP712.sol

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

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract VotingEIP712 {
    using ECDSA for bytes32;

    struct Proposal {
        bytes32 name;
        uint256 voteCount;
    }

    mapping(address => bool) public voted;
    Proposal[] public proposals;

    bytes32 public DOMAIN_SEPARATOR;
    bytes32 public constant VOTE_TYPEHASH =
        keccak256("Vote(address voter,uint256 proposalId)");

    constructor(bytes32[] memory names) {
        uint256 chainId;
        assembly { chainId := chainid() }

        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("VotingEIP712")),
                keccak256(bytes("1")),
                chainId,
                address(this)
            )
        );

        for (uint256 i; i < names.length; ) {
            proposals.push(Proposal({name: names[i], voteCount: 0}));
            unchecked { ++i; }
        }
    }

    function submitVote(address voter, uint256 proposalId, bytes calldata sig) external {
        require(!voted[voter], "Already voted");

        bytes32 digest = keccak256(
            abi.encodePacked(
                "\x19\x01",
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(VOTE_TYPEHASH, voter, proposalId))
            )
        );

        address signer = digest.recover(sig);
        require(signer == voter, "Invalid signature");

        voted[voter] = true;
        proposals[proposalId].voteCount++;
    }
}
Enter fullscreen mode Exit fullscreen mode

💻 Step 6: Frontend Integration (Ethers.js + React)

Here’s how to use Ethers.js with bytes32 names and EIP-712 signing.

vote.js

import { ethers } from "ethers";
import abi from "./VotingEIP712.json"; // ABI from compiled contract

const contractAddress = "YOUR_DEPLOYED_CONTRACT";

export async function signVote(proposalId) {
  const provider = new ethers.BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  const voter = await signer.getAddress();

  const domain = {
    name: "VotingEIP712",
    version: "1",
    chainId: (await provider.getNetwork()).chainId,
    verifyingContract: contractAddress,
  };

  const types = {
    Vote: [
      { name: "voter", type: "address" },
      { name: "proposalId", type: "uint256" },
    ],
  };

  const value = { voter, proposalId };
  const signature = await signer.signTypedData(domain, types, value);

  const contract = new ethers.Contract(contractAddress, abi, signer);
  const tx = await contract.submitVote(voter, proposalId, signature);
  await tx.wait();

  console.log("✅ Vote submitted for proposal", proposalId);
}
Enter fullscreen mode Exit fullscreen mode

bytes32 name conversion

import { ethers } from "ethers";

const name = ethers.encodeBytes32String("Option1");
const readable = ethers.decodeBytes32String(name);
Enter fullscreen mode Exit fullscreen mode

⚙️ Step 7: Compare Gas Usage

Version Vote Gas (approx) Notes
VotingString.sol 55,000 uses dynamic strings
Voting.sol 39,000 packed + bytes32
VotingEIP712.sol 25,000 (relayer) off-chain signatures

📣 Step 8: Wrap Up

Today, you learned how to:

  • Design a cheap, efficient, and secure voting system.
  • Use calldata, memory, and storage wisely.
  • Optimize loops and structs.
  • Deploy, test, and measure gas with Foundry.
  • Connect your contract with Ethers.js and React.

🔗 Follow Me

Top comments (0)