Appearance
L3-1: Offchain Voting(链下投票)
1. 问题
DAO 治理面临一个核心矛盾:投票需要广泛的社区参与,但链上投票每笔交易都需要支付 Gas 费用。对于持有少量代币的用户来说,Gas 成本可能超过投票本身的经济价值,导致投票率极低(大型 DAO 参与率通常在 1-5%)。同时,将所有投票数据直接存储在链上会造成巨大的存储成本——一个有 10 万投票者的提案,仅存储投票记录就需要数十 MB 的链上空间。
如何在保证投票结果可验证、不可篡改的前提下,让投票者零 Gas 参与,同时将链上存储成本降到 O(1)?这就是 Snapshot 等链下投票系统正在解决的问题。
2. 原因
Snapshot 已经成为 DAO 治理的事实标准,处理着数百个 DAO 的数十亿投票权。其核心思想借鉴了 Layer 2 扩容的思路:把昂贵的计算和存储放在链下,只在链上结算最终结果。用户通过 EIP-712 签名表达投票意愿(免费),后端收集所有签名后构建 Merkle Tree,再将 Merkle Root 和最终计票结果提交到链上。
理解链下投票的技术架构是参与 DAO 治理基础设施开发的必备技能。它涉及 EIP-712 签名标准、Merkle Tree 的密码学保证、以及链下计算链上验证的信任模型。这套模式不仅用于投票,也广泛应用于空投认领、白名单验证和去中心化身份系统。
3. 方案
OffchainVoting.sol 实现了三阶段流程:
阶段一:创建提案。createProposal() 在链上创建提案并记录投票的时间窗口(以区块号计)。提案本身存储在链上以保证其存在性。
阶段二:链下投票收集。在投票窗口期间,用户在链下使用 EIP-712 签名自己的投票选择(支持/反对)及其权重。后端收集所有签名,构建一棵 Merkle Tree,每个叶子节点是 keccak256(abi.encodePacked(voter, weight, support)) 的哈希。
阶段三:链上结算。submitResults() 将投票汇总(forVotes、againstVotes)和 Merkle Root 提交到链上。此时提案被标记为 settled。任何人随后可以调用 verifyVote() 来验证某个特定投票是否被包含在结果中——这通过 Merkle Proof 验证实现,每次验证的 Gas 成本为 O(log n),其中 n 是投票者数量。
核心技术细节:
- Leaf 哈希计算:
keccak256(abi.encodePacked(voter, weight, support)),使用abi.encodePacked紧凑编码以减少证明数据量 - Merkle Proof 验证使用双哈希排序配对(
computedHash <= proofElement判断顺序)防止二阶原像攻击 - 防重复验证:
voteVerified映射追踪每个投票者的验证状态 - 自定义错误(
ProposalAlreadySettled、InvalidMerkleProof等)替代 require string 以节省 Gas
参考源文件:src/level3/OffchainVoting.sol
4. 遭遇的陷阱
- Merkle Proof 排序不一致:链下 JavaScript 和链上 Solidity 对兄弟节点的哈希顺序必须完全一致,否则验证失败
- ABI 编码差异:
abi.encodePackedvsabi.encode对动态类型(string、bytes)的处理不同,使用错误编码方式会导致叶子哈希不匹配 - 签名格式不兼容:EIP-712 的
signTypedData与基础的eth_sign生成的签名格式不同,恢复签名者时需要匹配正确的消息前缀 - 前端状态不同步:链下提交 Merkle Root 后,如果用户在验证前尝试多次验证同一投票,会触发
VoteAlreadyVerified错误 - 包含排除攻击:提交者可以故意排除某些投票(因后端不在链上约束),唯一的防御是投票者自行验证自己的投票是否被计入
5. 陷阱的原因
Merkle Proof 排序问题的根源在于:链下 JavaScript 的 merkletreejs 库默认使用 sortPairs: true 按哈希值的字符串字典序排序,而 Solidity 合约中按数值大小比较。如果两者的比较逻辑不同,计算出的中间哈希值就不一致。更隐蔽的问题是,merkletreejs 使用的 Buffer 比较和 Solidity 的 bytes32 数值比较在处理前导零时可能产生不同的排序结果。
ABI 编码差异是因为 abi.encodePacked 不保留类型边界——多个动态类型参数紧密拼接,而 abi.encode 则使用标准的 32 字节对齐。当叶子节点包含 address(20 字节)和 uint256(32 字节)时,encodePacked 会产生 52 字节的输入,而链下的 ethers.solidityPacked 需要完全相同的参数顺序和类型。
6. 如何解决陷阱
Merkle Proof 排序一致性:在 Solidity 端(_verifyMerkleProof 函数)和 JavaScript 端使用相同的比较逻辑。推荐的方式是两边都使用 bytes32 的数值比较(uint256 转换),在 Solidity 中就是 computedHash <= proofElement。在 JavaScript 中使用 BigInt 比较:
javascript
// JavaScript 端确保和 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]));
}
}
叶子哈希一致性:在链下生成叶子哈希时,严格匹配合约中的编码方式:
javascript
const leaf = ethers.keccak256(
ethers.solidityPacked(
['address', 'uint256', 'bool'],
[voter, weight, support]
)
);
签名验证:在后端收集签名时,使用 EIP-712 标准格式并在链上(或在链下公开可验证的脚本中)验证每个签名的有效性后再构建 Merkle Tree。这确保了后端不能伪造不存在的投票。
7. 技术要点
| 技术点 | 说明 |
|---|---|
| EIP-712 签名 | 类型化结构化数据签名,用户可读的签名内容 |
| Merkle Tree | O(log n) 的验证复杂度,百万投票者仅需 20 层 |
| 双哈希排序配对 | 防止二阶原像攻击和跨实现不一致 |
abi.encodePacked | 紧凑编码,减少叶子大小和证明长度 |
| 链下计算 / 链上验证 | 投票收集在链下(零 Gas),结算在链上 |
| 自定义错误 | 4 字节 selector + 参数,比 require string 省 Gas |
voteVerified 防重验 | mapping 追踪验证状态,防止同一投票被多次验证计入 |
| 三阶段生命周期 | Create(链上)→ Vote(链下)→ Settle(链上) |