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:

ApproachHow it worksProsCons
External snapshot tokenUses a token contract that exposes historical balancesSimple integration, reusableRequires token support for snapshots
Internal checkpointingGovernance contract records checkpoints itselfFull controlMore 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:

  1. accept an ERC20 token address in the constructor
  2. create proposals with a snapshot block
  3. allow token holders to vote once per proposal
  4. tally votes as for, against, or abstain
  5. 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:

  • snapshotBlock to block.number + votingDelay
  • deadline to snapshotBlock + 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:

  1. checks that the proposal exists
  2. verifies the voting window is open
  3. ensures the voter has not already voted
  4. queries token balance at the snapshot block
  5. 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:

RulePurposeTypical risk if omitted
Snapshot blockFix voting power in timeBalance manipulation during voting
Voting delaySeparate proposal creation from votingSame-block gaming
QuorumEnsure participationLow-turnout capture
One vote per addressPrevent duplicate votesVote inflation
TimelockDelay executionSudden 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.

Learn more with useful resources