Skip to content
On this page

L3-1: Offchain Voting

1. Problem

DAO governance faces a fundamental contradiction: voting requires broad community participation, but every on-chain voting transaction incurs gas fees. For users holding small amounts of tokens, the gas cost may exceed the economic value of the vote itself, resulting in abysmal turnout (large DAOs typically see 1-5% participation). At the same time, storing all voting data directly on-chain creates enormous storage costs — a proposal with 100,000 voters requires tens of MB of on-chain space just for vote records.

How can we allow voters to participate with zero gas while ensuring that results remain verifiable and tamper-proof, and simultaneously reduce on-chain storage costs to O(1)? This is exactly the problem that off-chain voting systems like Snapshot are solving.

2. Rationale

Snapshot has become the de facto standard for DAO governance, processing billions in voting power across hundreds of DAOs. Its core idea borrows from Layer 2 scaling: put expensive computation and storage off-chain, and only settle the final result on-chain. Users express their voting intent via EIP-712 signatures (free of charge), the backend collects all signatures and builds a Merkle Tree, then submits the Merkle Root and final tally to the chain.

Understanding the technical architecture of off-chain voting is essential for anyone working on DAO governance infrastructure. It involves the EIP-712 signature standard, cryptographic guarantees of Merkle Trees, and the trust model of off-chain computation with on-chain verification. This pattern is used not only for voting but also widely for airdrop claims, whitelist verification, and decentralized identity systems.

3. Solution

OffchainVoting.sol implements a three-phase workflow:

Phase 1: Proposal Creation. createProposal() creates a proposal on-chain and records the voting time window (measured in block numbers). The proposal itself is stored on-chain to guarantee its existence.

Phase 2: Off-Chain Vote Collection. During the voting window, users sign their vote choice (for/against) and weight off-chain using EIP-712 signatures. The backend collects all signatures, builds a Merkle Tree where each leaf node is the hash of keccak256(abi.encodePacked(voter, weight, support)).

Phase 3: On-Chain Settlement. submitResults() submits the vote summary (forVotes, againstVotes) and the Merkle Root on-chain. At this point the proposal is marked as settled. Anyone can subsequently call verifyVote() to verify whether a specific vote was included in the results — this is done through Merkle Proof verification, with a gas cost of O(log n) per verification, where n is the number of voters.

Key technical details:

  • Leaf hash computation: keccak256(abi.encodePacked(voter, weight, support)), using abi.encodePacked for compact encoding to reduce proof data size
  • Merkle Proof verification uses double-hash sorted pairing (computedHash <= proofElement to determine ordering) to prevent second preimage attacks
  • Duplicate verification prevention: the voteVerified mapping tracks each voter's verification status
  • Custom errors (ProposalAlreadySettled, InvalidMerkleProof, etc.) replace require strings to save gas

Reference source file: src/level3/OffchainVoting.sol

4. Pitfalls Encountered

  • Merkle Proof ordering inconsistency: the sibling hash ordering between off-chain JavaScript and on-chain Solidity must be exactly identical, otherwise verification fails
  • ABI encoding differences: abi.encodePacked vs abi.encode handle dynamic types (string, bytes) differently; using the wrong encoding method causes leaf hash mismatches
  • Signature format incompatibility: EIP-712 signTypedData and basic eth_sign produce different signature formats; recovering the signer requires matching the correct message prefix
  • Frontend state desynchronization: after the Merkle Root is submitted off-chain, if a user attempts to verify the same vote multiple times before verification completes, it triggers the VoteAlreadyVerified error
  • Exclusion attacks: the submitter can intentionally exclude certain votes (since the backend is not constrained on-chain); the only defense is for voters to verify their own votes were included

5. Root Causes of Pitfalls

The Merkle Proof ordering issue stems from: off-chain JavaScript's merkletreejs library defaults to sortPairs: true, sorting by string lexicographic order of hashes, while the Solidity contract compares by numeric value. If the two comparison logics differ, the computed intermediate hashes will not match. A more subtle issue is that merkletreejs uses Buffer comparison while Solidity's bytes32 numeric comparison can produce different sort results when handling leading zeros.

ABI encoding differences occur because abi.encodePacked does not preserve type boundaries — multiple dynamic type parameters are tightly packed, while abi.encode uses standard 32-byte alignment. When a leaf node contains an address (20 bytes) and a uint256 (32 bytes), encodePacked produces 52 bytes of input, and the off-chain ethers.solidityPacked needs exactly the same parameter order and types.

6. How to Resolve the Pitfalls

Merkle Proof ordering consistency: use the same comparison logic in both the Solidity side (_verifyMerkleProof function) and the JavaScript side. The recommended approach is to use bytes32 numeric comparison (uint256 conversion) on both sides; in Solidity this is computedHash <= proofElement. In JavaScript, use BigInt comparison:

javascript
// JavaScript side: ensure ordering consistent with Solidity
function hashPair(a, b) {
  const aBig = BigInt(a);
  const bBig = BigInt(b);
  if (aBig <= bBig) {
    return keccak256(solidityPacked(['bytes32', 'bytes32'], [a, b]));
  } else {
    return keccak256(solidityPacked(['bytes32', 'bytes32'], [b, a]));
  }
}

Leaf hash consistency: when generating leaf hashes off-chain, strictly match the encoding method in the contract:

javascript
const leaf = ethers.keccak256(
  ethers.solidityPacked(
    ['address', 'uint256', 'bool'],
    [voter, weight, support]
  )
);

Signature verification: when collecting signatures on the backend, use the EIP-712 standard format and verify the validity of each signature on-chain (or through a publicly verifiable off-chain script) before building the Merkle Tree. This ensures the backend cannot forge nonexistent votes.

7. Technical Highlights

TechniqueDescription
EIP-712 SignaturesTyped structured data signatures with human-readable content
Merkle TreeO(log n) verification complexity; one million voters need only 20 layers
Double-hash Sorted PairingPrevents second preimage attacks and cross-implementation inconsistencies
abi.encodePackedCompact encoding, reduces leaf size and proof length
Off-chain Compute / On-chain VerifyVote collection off-chain (zero gas), settlement on-chain
Custom Errors4-byte selector + parameters, saves gas over require strings
voteVerified Anti-replayMapping tracks verification status, prevents double-counting the same vote
Three-phase LifecycleCreate (on-chain) → Vote (off-chain) → Settle (on-chain)

Built with AiAda