Appearance
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)), usingabi.encodePackedfor compact encoding to reduce proof data size - Merkle Proof verification uses double-hash sorted pairing (
computedHash <= proofElementto determine ordering) to prevent second preimage attacks - Duplicate verification prevention: the
voteVerifiedmapping 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.encodePackedvsabi.encodehandle dynamic types (string, bytes) differently; using the wrong encoding method causes leaf hash mismatches - Signature format incompatibility: EIP-712
signTypedDataand basiceth_signproduce 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
VoteAlreadyVerifiederror - 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
| Technique | Description |
|---|---|
| EIP-712 Signatures | Typed structured data signatures with human-readable content |
| Merkle Tree | O(log n) verification complexity; one million voters need only 20 layers |
| Double-hash Sorted Pairing | Prevents second preimage attacks and cross-implementation inconsistencies |
abi.encodePacked | Compact encoding, reduces leaf size and proof length |
| Off-chain Compute / On-chain Verify | Vote collection off-chain (zero gas), settlement on-chain |
| Custom Errors | 4-byte selector + parameters, saves gas over require strings |
voteVerified Anti-replay | Mapping tracks verification status, prevents double-counting the same vote |
| Three-phase Lifecycle | Create (on-chain) → Vote (off-chain) → Settle (on-chain) |