Skip to content
On this page

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() 将投票汇总(forVotesagainstVotes)和 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 映射追踪每个投票者的验证状态
  • 自定义错误(ProposalAlreadySettledInvalidMerkleProof 等)替代 require string 以节省 Gas

参考源文件:src/level3/OffchainVoting.sol

4. 遭遇的陷阱

  • Merkle Proof 排序不一致:链下 JavaScript 和链上 Solidity 对兄弟节点的哈希顺序必须完全一致,否则验证失败
  • ABI 编码差异abi.encodePacked vs abi.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 TreeO(log n) 的验证复杂度,百万投票者仅需 20 层
双哈希排序配对防止二阶原像攻击和跨实现不一致
abi.encodePacked紧凑编码,减少叶子大小和证明长度
链下计算 / 链上验证投票收集在链下(零 Gas),结算在链上
自定义错误4 字节 selector + 参数,比 require string 省 Gas
voteVerified 防重验mapping 追踪验证状态,防止同一投票被多次验证计入
三阶段生命周期Create(链上)→ Vote(链下)→ Settle(链上)

Built with AiAda