“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
, andcalldata
- 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
⚙️ Step 1: Setup the Project
Install Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup
Initialize the project:
forge init day-15-voting
cd day-15-voting
🧱 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;
}
}
⚡ 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);
}
}
Run the test and see gas usage:
forge test --gas-report
🪶 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();
}
}
Deploy locally:
forge script script/Deploy.s.sol:DeployScript --rpc-url http://127.0.0.1:8545 --broadcast
🔐 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++;
}
}
💻 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);
}
bytes32
name conversion
import { ethers } from "ethers";
const name = ethers.encodeBytes32String("Option1");
const readable = ethers.decodeBytes32String(name);
⚙️ 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
, andstorage
wisely. - Optimize loops and structs.
- Deploy, test, and measure gas with Foundry.
- Connect your contract with Ethers.js and React.
🔗 Follow Me
- 🧑💻 Saurav Kumar
- 💬 Twitter/X: @_SauravCodes
- 💼 LinkedIn: Saurav Kumar
Top comments (0)