A full-stack, auditable voting system built on Ethereum. Designed for student/college elections but extensible to any bounded-electorate vote (corporate governance, union elections, DAO proposals, etc.).
┌─────────────┐ HTTPS ┌──────────────┐ JSON-RPC ┌──────────────────┐
│ React │ ─────────▶ │ Express API │ ───────────▶ │ Ethereum │
│ Frontend │ │ + SQLite │ │ (Hardhat/ │
│ (MetaMask) │ ◀──────── │ voter DB │ ◀─────────── │ Sepolia) │
│ │ signed │ JWT auth │ │ │
│ │ tx │ │ │ Voting.sol │
└─────────────┘ └──────────────┘ └──────────────────┘
| Property | Enforced by | How |
|---|---|---|
| One vote per wallet | Smart contract | hasVoted mapping checked before every vote |
| Only approved voters can vote | Smart contract | isRegistered whitelist (admin-controlled) |
| Votes only during window | Smart contract | block.timestamp check against startTime/endTime |
| Only admin changes candidates/voters | Smart contract | onlyAdmin modifier (immutable admin address) |
| No candidate additions after start | Smart contract | Reverts once block.timestamp >= startTime |
| Reentrancy safety | Smart contract | nonReentrant guard on castVote |
| Auditable vote trail | Smart contract | Every vote emits a VoteCast event anyone can query |
| Identity ↔ wallet binding | Backend | Bcrypt-hashed password, wallet address pinned at registration |
| Brute-force resistance | Backend | Rate limiter on auth endpoints (20 req / 15 min) |
| Session security | Backend | Short-lived JWT (12h), HTTPS headers via Helmet |
| Secrets never exposed | Frontend | Users sign votes in their own wallet; never share keys |
- Smart contracts: Solidity ^0.8.24, Hardhat
- Blockchain: Local Hardhat node (dev) → Sepolia (production)
- Backend: Node.js, Express, SQLite (better-sqlite3), JWT, bcrypt, ethers.js v6
- Frontend: React 18, Vite, ethers.js v6, Tailwind CSS v3, Framer Motion, Recharts, PapaParse, MetaMask via
window.ethereum
- Civic Editorial Aesthetic: A clean, modern interface using Fraunces and JetBrains Mono with warm paper tones and an integrated Dark Mode toggle.
- Rich Interactions: Powered by Framer Motion, featuring staggered entrances, interactive hover states, pulse animations, and animated confirmation modals.
- Live Data Visualizations: Real-time updating bar charts using Recharts for live election results.
- Admin Tools: Bulk voter approval via CSV upload (PapaParse) to streamline large-scale elections.
- Skeleton Loaders: Polished initial loading states for an uninterrupted user experience.
secure-voting/
├── contracts/Voting.sol # The election smart contract
├── scripts/deploy.js # Deploys + copies ABI to backend/frontend
├── test/Voting.test.js # Full contract test suite
├── hardhat.config.js
├── backend/
│ └── src/
│ ├── server.js # Express API
│ └── db.js # SQLite voter registry
├── frontend/
│ └── src/
│ ├── App.jsx # Main React app
│ ├── components/ # AuthBox, Ballot, AdminPanel, AuditLog
│ ├── hooks/useWallet.js # MetaMask connection
│ └── utils/api.js # Backend client
└── deployments/ # Auto-generated contract addresses per network
git clone <this-repo> secure-voting
cd secure-voting
npm run install:allThis installs Hardhat (root), the backend, and the frontend in one go.
In a dedicated terminal:
npx hardhat nodeHardhat prints ~20 test accounts with 10,000 ETH each. Copy account #0's private key — that will be your admin wallet. Also import a few of the other accounts into MetaMask for voter testing.
In a second terminal:
npm run deploy:localYou should see:
Voting deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Wrote contract.json to backend and frontend.
This script writes backend/src/contract.json and frontend/src/contract.json containing the deployed address + ABI — both apps pick that up automatically.
cd backend
cp .env.example .envEdit .env and set:
ADMIN_PRIVATE_KEY— the private key of Hardhat account #0 (from step 2)JWT_SECRET— any long random stringBOOTSTRAP_ADMIN_PASSWORD_HASH— generate with:node -e "console.log(require('bcryptjs').hashSync('admin123', 12))"BOOTSTRAP_ADMIN_WALLET— Hardhat account #0's public address
Then start the backend:
npm startExpected output:
Bootstrapped admin user: admin
Loaded contract at: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Admin wallet configured: 0xf39F...
Backend listening on http://localhost:4000
In MetaMask → Add a custom network:
- Network name:
Hardhat Local - RPC URL:
http://127.0.0.1:8545 - Chain ID:
31337 - Currency:
ETH
Import a few Hardhat-provided private keys as accounts.
In a third terminal:
cd frontend
cp .env.example .env # Default values are fine for local
npm run devVisit http://localhost:5173.
- Click Connect wallet → pick a voter MetaMask account
- Click Register tab, pick a username + password (≥8 chars)
- Click Login
- Wait for an admin to approve you (see below)
- Once approved, select a candidate and click Cast ballot
- MetaMask prompts you to sign the transaction — confirm
- Your vote is recorded on-chain. The audit ledger updates live.
- Log in as
admin(password = whatever you set during backend setup) - Connect MetaMask with the admin wallet (Hardhat account #0)
- Add candidates — must be done before the election
startTime - Approve voters — each approval submits an on-chain
registerVoter()tx. You can also use the Bulk Approve via CSV tool to upload a list of usernames. - After
endTimehas passed, click Finalize election to lock results
- Get free Sepolia ETH from https://sepoliafaucet.com
- Get an RPC URL from Alchemy or Infura
- Edit the root
.env:cp .env.example .env # fill in SEPOLIA_RPC_URL and PRIVATE_KEY - Deploy:
npm run deploy:sepolia
- Update
backend/.envwith the new SepoliaRPC_URLandADMIN_PRIVATE_KEY - Update
frontend/.envwithVITE_CHAIN_ID=11155111 - Restart backend + frontend
npm testTests cover:
- Constructor validation
- Admin access control
- Candidate management (before/after election start)
- Voter registration (single + batch)
- Voting (success, double-vote, unregistered, invalid candidate, outside window)
- Winner computation
- Finalization
What this protects against:
- Ballot stuffing (one vote per wallet, enforced on-chain)
- Admin tampering (admin can't modify votes after they're cast; events are immutable)
- Off-hours voting (time-bounded)
- Vote count forgery (counts come from on-chain state, not the backend DB)
What this does NOT protect against (by design, for a student election scope):
- Vote secrecy — all votes are public. For secret ballots, upgrade to a commit-reveal scheme or use zk-SNARKs (e.g., Semaphore protocol).
- Coercion / vote buying — if votes are public, buyers can verify purchases. Same mitigations as above.
- Sybil attacks via the off-chain registry — depends on how rigorously the admin vets identity before approving a wallet. For real student elections, bind to SSO (e.g., university SAML) instead of username/password.
- Compromised admin key — admin can approve fake voters. Mitigate by using a multisig (Gnosis Safe) as the contract admin and decentralizing approval.
- RPC provider trust — the frontend trusts the RPC endpoint for reads. For critical elections, users should verify against multiple RPC providers or run their own node.
For a real, high-stakes election you'd want to add:
- Secret ballots via commit-reveal
- Voter submits
keccak256(candidateId, salt)during the voting phase - Reveals
(candidateId, salt)during a separate reveal phase
- Voter submits
- zk-voting (Semaphore, MACI) for anonymous but verifiable votes
- Multisig admin — replace the single
adminwith a Gnosis Safe - Merkle-tree voter whitelist — scales cheaply to millions of voters
- SSO identity binding — OAuth/SAML against the institution's IDP
- Formal verification — run Certora or Slither over the contract
- Professional audit before ever handling a real election
MIT — use it, fork it, run elections with it.