
Building a Secure ERC20 Snapshot-Based Voting Contract in Solidity
Why snapshot-based voting matters
In a standard token-based vote, a user could transfer tokens immediately before or during a vote to influence the result. Snapshot-based voting prevents that by binding voting power to a past block number. If the snapshot is taken before the proposal starts, token movements after that point do not affect the outcome.
This pattern is especially valuable for:
- DAO governance
- Community treasury approvals
- Protocol parameter changes
- Token-holder referendums
- Off-chain coordination with on-chain enforcement
A secure implementation should ensure that:
- each proposal has a fixed snapshot block
- each voter can vote only once
- voting power is derived from historical balances
- vote totals cannot be changed after casting
- the contract handles token contracts that support historical balance queries
Design overview
There are two common ways to implement snapshot voting:
| Approach | How it works | Pros | Cons |
|---|---|---|---|
| External snapshot token | Uses a token contract that exposes historical balances | Simple integration, reusable | Requires token support for snapshots |
| Internal checkpointing | Governance contract records checkpoints itself | Full control | More code, more storage, more gas |
In this tutorial, we use an ERC20 token that supports snapshot queries. The governance contract asks the token for a voter’s balance at the proposal’s snapshot block. This keeps the voting contract compact and easier to audit.
Core contract structure
The contract will:
- accept an ERC20 token address in the constructor
- create proposals with a snapshot block
- allow token holders to vote once per proposal
- tally votes as
for,against, orabstain - finalize the proposal after voting ends
A practical implementation should also include proposal deadlines and a minimum delay between proposal creation and voting start. That gives token holders time to inspect the proposal before voting begins.
Example implementation
The example below uses OpenZeppelin-style interfaces and a minimal snapshot-capable token interface. In production, you would typically integrate with an ERC20Votes-style token or a token that provides historical balance lookup.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20SnapshotLike {
function balanceOfAt(address account, uint256 blockNumber) external view returns (uint256);
}
contract SnapshotVoting {
enum VoteType {
Against,
For,
Abstain
}
struct Proposal {
address proposer;
string description;
uint256 snapshotBlock;
uint256 deadline;
uint256 forVotes;
uint256 againstVotes;
uint256 abstainVotes;
bool executed;
}
IERC20SnapshotLike public immutable votingToken;
uint256 public proposalCount;
uint256 public votingDelay;
uint256 public votingPeriod;
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
event ProposalCreated(
uint256 indexed proposalId,
address indexed proposer,
uint256 snapshotBlock,
uint256 deadline,
string description
);
event VoteCast(
uint256 indexed proposalId,
address indexed voter,
VoteType voteType,
uint256 weight
);
event ProposalExecuted(uint256 indexed proposalId, bool passed);
constructor(address tokenAddress, uint256 _votingDelay, uint256 _votingPeriod) {
require(tokenAddress != address(0), "token required");
require(_votingDelay > 0, "delay required");
require(_votingPeriod > 0, "period required");
votingToken = IERC20SnapshotLike(tokenAddress);
votingDelay = _votingDelay;
votingPeriod = _votingPeriod;
}
function createProposal(string calldata description) external returns (uint256 proposalId) {
proposalId = ++proposalCount;
uint256 snapshotBlock = block.number + votingDelay;
uint256 deadline = snapshotBlock + votingPeriod;
proposals[proposalId] = Proposal({
proposer: msg.sender,
description: description,
snapshotBlock: snapshotBlock,
deadline: deadline,
forVotes: 0,
againstVotes: 0,
abstainVotes: 0,
executed: false
});
emit ProposalCreated(
proposalId,
msg.sender,
snapshotBlock,
deadline,
description
);
}
function castVote(uint256 proposalId, VoteType voteType) external {
Proposal storage proposal = proposals[proposalId];
require(proposal.proposer != address(0), "proposal not found");
require(block.number >= proposal.snapshotBlock, "voting not started");
require(block.number <= proposal.deadline, "voting ended");
require(!hasVoted[proposalId][msg.sender], "already voted");
uint256 weight = votingToken.balanceOfAt(msg.sender, proposal.snapshotBlock);
require(weight > 0, "no voting power");
hasVoted[proposalId][msg.sender] = true;
if (voteType == VoteType.For) {
proposal.forVotes += weight;
} else if (voteType == VoteType.Against) {
proposal.againstVotes += weight;
} else {
proposal.abstainVotes += weight;
}
emit VoteCast(proposalId, msg.sender, voteType, weight);
}
function executeProposal(uint256 proposalId) external {
Proposal storage proposal = proposals[proposalId];
require(proposal.proposer != address(0), "proposal not found");
require(block.number > proposal.deadline, "voting active");
require(!proposal.executed, "already executed");
proposal.executed = true;
bool passed = proposal.forVotes > proposal.againstVotes;
emit ProposalExecuted(proposalId, passed);
}
function proposalResult(uint256 proposalId) external view returns (bool passed, uint256 forVotes, uint256 againstVotes, uint256 abstainVotes) {
Proposal storage proposal = proposals[proposalId];
require(proposal.proposer != address(0), "proposal not found");
passed = proposal.forVotes > proposal.againstVotes;
forVotes = proposal.forVotes;
againstVotes = proposal.againstVotes;
abstainVotes = proposal.abstainVotes;
}
}How the contract works
Proposal creation
When a proposal is created, the contract sets:
snapshotBlocktoblock.number + votingDelaydeadlinetosnapshotBlock + votingPeriod
This means voting cannot begin immediately. The delay is important because it prevents a proposer from creating a proposal and voting in the same block with freshly acquired tokens.
Vote casting
When a voter calls castVote, the contract:
- checks that the proposal exists
- verifies the voting window is open
- ensures the voter has not already voted
- queries token balance at the snapshot block
- records the vote weight and choice
Because the weight is read from a historical block, later token transfers do not affect the vote.
Proposal execution
The sample executeProposal function only records whether the proposal passed. In a real governance system, execution could trigger:
- treasury transfers
- parameter updates
- role changes
- queued calls to other contracts
For safety, execution should usually be separated from voting and protected by additional checks, such as quorum and timelocks.
Security considerations
Snapshot voting is safer than live-balance voting, but it still needs careful design.
1. Prevent double voting
A voter must only be able to vote once per proposal. The hasVoted mapping enforces this. Without it, a user could repeatedly cast votes and inflate totals.
2. Use a stable snapshot source
The token contract must provide trustworthy historical balances. If the token’s snapshot mechanism can be manipulated or overwritten, voting integrity is compromised. Prefer well-tested implementations such as ERC20Votes-style tokens.
3. Define quorum rules
The example above uses a simple majority rule. Many governance systems also require quorum, such as “at least 10% of total supply must participate.” Without quorum, a tiny number of voters could pass proposals.
A quorum check might look like this:
require(proposal.forVotes + proposal.againstVotes >= quorum, "quorum not met");4. Avoid ambiguous execution timing
If execution is allowed too early, voters may not have enough time to participate. If it is allowed too late, proposals may linger indefinitely. Use a fixed voting period and consider a post-vote execution delay for sensitive actions.
5. Be careful with proposal spam
If proposal creation is unrestricted, attackers can flood the system with low-quality proposals. Common mitigations include:
- proposal deposits
- proposer thresholds
- minimum token holdings
- rate limits per address
6. Handle token supply changes
Snapshot voting is based on historical balances, but total supply may still change over time. If quorum depends on supply, decide whether to use:
- total supply at snapshot block
- current total supply
- a fixed governance supply baseline
The choice affects fairness and predictability.
Best practices for production governance
A production-grade voting contract usually includes more than the minimal example. Consider the following enhancements:
- Proposal metadata hashing: store a hash of structured proposal data instead of only a description string.
- Quorum and threshold logic: require minimum participation and support.
- Timelocked execution: delay execution after a proposal passes.
- Delegation support: allow holders to delegate voting power.
- Upgradeable governance modules: separate voting, execution, and proposal management.
- Event-rich design: emit events for every state transition to simplify indexing and off-chain monitoring.
The table below summarizes common governance rules and their effects:
| Rule | Purpose | Typical risk if omitted |
|---|---|---|
| Snapshot block | Fix voting power in time | Balance manipulation during voting |
| Voting delay | Separate proposal creation from voting | Same-block gaming |
| Quorum | Ensure participation | Low-turnout capture |
| One vote per address | Prevent duplicate votes | Vote inflation |
| Timelock | Delay execution | Sudden harmful changes |
Testing scenarios you should cover
Before deploying, test the contract against realistic edge cases:
- a user transfers tokens after the snapshot and should not gain extra voting power
- a user with zero balance at the snapshot cannot vote
- a user cannot vote twice
- voting reverts before the snapshot block
- voting reverts after the deadline
- execution cannot happen before voting ends
- proposal results are consistent with recorded vote weights
It is also worth testing with multiple token holders and varying balances to ensure the historical balance lookup behaves as expected.
When snapshot voting is the right choice
Use snapshot-based voting when you need:
- predictable governance power
- resistance to transfer-based vote manipulation
- a clear voting cutoff
- compatibility with token-holder governance
It is less suitable when you need continuous, real-time voting power or when token balances are not a good proxy for decision-making authority. In those cases, you may need reputation-based systems, identity-based voting, or more advanced governance logic.
Conclusion
Snapshot-based voting is a practical and widely used pattern for token governance in Solidity. By anchoring voting power to a fixed block, you reduce manipulation risk and make governance outcomes easier to reason about. The key implementation details are simple, but the security requirements are not: enforce one vote per address, use a reliable snapshot source, define quorum, and separate voting from execution.
A well-designed snapshot voting contract gives token holders a fair and auditable way to participate in protocol decisions without exposing the system to last-minute balance shifts.
