ChainVote
unknownA voting platform where the tally lives in a smart contract rather than a database, and every ballot is a publicly verifiable transaction.
Architecture
- Next.js 14 client (App Router), client. Renders election lists, the voting flow, and live results; results pages are read-only and work with no wallet installed. Connected to lib/adapters/wallet.ts. Connected to lib/services/blockchain.ts.
- lib/adapters/wallet.ts (EIP-1193 singleton), client. One singleton that owns MetaMask detection, account and chain state, and network switching. Connected from Next.js 14 client.
- lib/services/blockchain.ts (read/write split), client. The one module through which every chain interaction flows, split into a read path and a write path. Connected from Next.js 14 client. Connected to Public RPC pool over read. Connected to MetaMask signer over write.
- Public RPC pool (ordered failover), external. An ordered list of public JSON-RPC providers, probed for health, cached, and rotated when a node dies mid-session. Connected from lib/services/blockchain.ts over read. Connected to ElectionFactory contract over JSON-RPC.
- MetaMask signer, external. Holds the keys and signs every write. The app never touches a private key. Connected from lib/services/blockchain.ts over write. Connected to ElectionFactory contract over signed tx.
- ElectionFactory contract (125 lines Solidity), service. Deploys each election as its own contract and is the single known address from which every election is discovered. Connected from Public RPC pool over JSON-RPC. Connected from MetaMask signer over signed tx. Connected to Election contracts over deploys.
- Election contracts (one per election, 267 lines), service. Enforces the rules per election: whitelist membership, one vote per address, valid candidate identifier, open time window, admin-only actions. Connected from ElectionFactory contract over deploys. Connected to PolygonScan over verification.
- PolygonScan (verification), external. Independent verification: contract source, every transaction, every event, readable by anyone. Connected from Election contracts over verification.
- Boundary: Polygon Amoy (chain 80002), enforcement lives here.
Trace
liveFires one real read-only JSON-RPC call at the public Amoy pool the app itself reads through, and times each hop.
Decisions
Enforcement lives in the contract
- The decision
- Whitelist membership, one vote per address, valid candidate identifier, open time window, and admin-only actions are all enforced in Solidity and independently tested.
- The constraint that forced it
- Anyone can call a public contract directly from a script. The interface is not a boundary.
- Rejected
- Interface-level enforcement. Disabling a button is not a security control.
- Consequence accepted
- Calling the contract directly bypasses nothing, and every rule costs gas because it runs on chain.
A factory per deployment, a contract per election
- The decision
- ElectionFactory deploys each election as its own contract.
- The constraint that forced it
- One election's state must not be able to corrupt another's.
- Rejected
- A monolithic contract with every election in mappings.
- Consequence accepted
- Each election gets its own explorer page, and the frontend needs exactly one known address to discover all of them.
Reads and writes are split
- The decision
- Reads go through a public RPC pool with no wallet required; writes go through the MetaMask signer.
- The constraint that forced it
- Public results pages must work with MetaMask not installed at all.
- Rejected
- Routing everything through the wallet's provider.
- Consequence accepted
- Two code paths to maintain, and the read path needs its own failover story.
A production DNS incident, diagnosed and designed out
- The decision
- The read path walks an ordered list of providers, probes for the first healthy one, caches it, rotates automatically if a node dies mid-session, and filters the retired host by name.
- The constraint that forced it
- Polygon retired its public Amoy RPC endpoint and the hostname stopped resolving in DNS. Every read in the deployed app died as a bare "Failed to fetch" and the site went blank. The failure was traced from the browser console down to DNS.
- Rejected
- Pointing the environment variable at a different single provider.
- Consequence accepted
- A stale environment variable can no longer take the app down, at the cost of a health probe on cold start.
Event history under free infrastructure
- The decision
- The log reader locates the election's creation block by binary search over block timestamps, walks the range in 9,000-block chunks, detects pruning responses specifically, rotates to a node that retained the history, and applies timeouts at every stage with one retry on transient failures.
- The constraint that forced it
- Public RPCs reject unbounded eth_getLogs and most prune old blocks, returning "history has been pruned" rather than an error, which silently reads as "no votes."
- Rejected
- One eth_getLogs call over the full range, which free providers reject.
- Consequence accepted
- Reading history is slower and chattier, but it is correct on pruned nodes and it can tell "no votes" from "no data."
Chain-specific gas handling
- The decision
- The write path reads live fee data, applies a 25% buffer, and floors the price at 30 gwei.
- The constraint that forced it
- Amoy enforces a minimum gas price that wallets do not always fetch correctly, causing first-attempt failures.
- Rejected
- Letting the wallet estimate on its own.
- Consequence accepted
- Votes cost marginally more than the theoretical minimum, and the floor is a constant to revisit if the chain changes.
Custom errors, immutable storage
- The decision
- Custom Solidity error types instead of require strings, and immutable storage for the owner and factory addresses.
- The constraint that forced it
- Every byte of revert data and every storage slot costs gas.
- Rejected
- require with revert strings: cheaper to write, costlier to run.
- Consequence accepted
- Cheaper reverts, typed errors on the client, and addresses fixed at deployment.
ABI as signature strings
- The decision
- Contract interfaces are stored as human-readable ethers signature strings rather than compiled JSON artifacts.
- The constraint that forced it
- The frontend bundle and code review both pay for every artifact byte.
- Rejected
- Importing compiled ABI JSON from the build output.
- Consequence accepted
- The bundle stays small and the ABI is readable in a diff, but signatures are updated by hand when the contract changes.
Known limitations
- Vote choice is hidden at the interface layer: the UI never exposes it and there is no getter for another voter's choice. But all chain state is publicly readable regardless of Solidity visibility, and VoteCast emits candidateId as an indexed event parameter, so who voted for whom is derivable from the chain. This is not ballot secrecy in the cryptographic sense. Achieving that would require commit-reveal or zero-knowledge proofs, which was out of scope for this build and is the natural next step.