ERC20Votes 详解:委托投票、检查点与 ERC-5805/6372
目录
- 一、ERC20Votes 是什么
- 二、为什么需要它:三大动机
- 三、ERC-5805 委托接口
- 四、检查点(Checkpoint)机制
- 五、ERC-6372 时钟标准
- 六、ERC20Votes vs ERC20Snapshot
- 七、如何选择
- 八、总结
- 九、动手练习项目:委托治理代币 DelegateGov
一、ERC20Votes 是什么
ERC20Votes 是一个增强版 ERC20,加入了委托投票和检查点能力。澄清一点:
ERC20Votes 本身不负责举办投票,它仍是一个普通 ERC20,只是带有快照和委托投票的能力。
真正的投票流程由 Governor 合约(下一篇)处理。ERC20Votes 只负责”记录每个地址在每个时间点拥有多少投票权”。
二、为什么需要它:三大动机
- 被动参与:股东无需转移代币就能把投票权委托出去,让代理人替自己投;
- Gas 效率:小额持有者不用花 Gas 投票,由受托人代为投票;
- 防重复投票:检查点记录投票权历史,过去无法篡改。
三、ERC-5805 委托接口
3.1 必须先委托给自己才有票
这是最反直觉、也是面试高频考点的一点:
一个地址必须先委托给自己,它的票才会被计算。
为什么有这个”怪癖”?为了 Gas 效率。默认情况下持币不自动产生投票权——只有显式 delegate(自己) 后,转账才会去维护投票权检查点。这样不参与治理的普通持币者完全不产生检查点写入开销。委托是全有或全无,不支持按比例部分委托。
3.2 核心函数
| 函数 | 作用 |
|---|---|
getVotes(address) | 返回某账户当前投票权(可能 > 自己余额,因为别人委托给了它) |
delegate(address delegatee) | 把投票权委托给某地址 |
delegates(address) | 返回某账户委托给了谁 |
nonces(address) | 返回防签名重放的 nonce 计数器 |
事件:委托变化时发出 DelegateChanged 和 DelegateVotesChanged。
注意:ERC-5805 是一个接口,不是代币——它也可用于 NFT 或其他投票安排,不限于同质化代币。
3.3 签名委托 delegateBySig
function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s)
通过 EIP-712 签名实现无 Gas 委托:用户离线签名,第三方付 Gas 提交。nonce 防止签名被重放。
3.4 getPastVotes 与快照触发时机
getPastVotes(address account, uint256 timepoint)
查询某时间点的历史投票权。与 ERC20Snapshot 不同:
快照不是由某人调用 snapshot 函数触发的。
ERC20Votes 的检查点在以下事件自动产生:铸造、销毁、转账、委托。系统对时间戳做二分查找,定位查询时间点之后最早的检查点。
四、检查点(Checkpoint)机制
和 ERC20Snapshot 的”全局快照 ID”不同,ERC20Votes 维护每个账户各自的投票权历史:
每次上述事件发生时,一个包含投票权和时间戳的结构体被追加到该用户的投票权历史数组里。
struct Checkpoint {
uint48 fromTimepoint; // 或 uint32 区块号
uint208 votes; // 该时间点起的投票权
}
委托如何移动投票权:当 A 委托给 B,A 的余额对应的投票权从”A 之前的受托人”减去、加到 B 头上,两边各追加一个新检查点。转账时也类似地更新双方受托人的检查点。
五、ERC-6372 时钟标准
这个标准解决”用什么表示时间”的灵活性问题。
clock()
返回一个 uint48 表示当前时间——可以是区块号、时间戳,或派生函数。用 uint48 是因为它能表示的范围”比人类有记载的历史还遥远”。
CLOCK_MODE()
返回描述时钟类型的字符串:
| 模式 | 返回值 |
|---|---|
| 时间戳 | "mode=timestamp" |
| 区块号 | "mode=blocknumber&from=default" |
| 自定义起点 | "mode=blocknumber&from=[chainid]:[blocknumber]" |
例:Avalanche(链 ID 43114)从区块 100 起,返回 "mode=blocknumber&from=43114:100"。
为什么需要它?不同链出块时间不同,有的治理想按区块号计时、有的想按时间戳。ERC-6372 让 Governor 能统一地询问”现在几点了”。
六、ERC20Votes vs ERC20Snapshot
| 方面 | ERC20Votes | ERC20Snapshot |
|---|---|---|
| 时间概念 | 显式的时间概念(时钟) | 自增的计数器 ID |
| 记录什么 | 投票权快照 | 代币余额快照 |
| 检查点触发 | 委托/转账等事件自动 | 显式调用 _snapshot() |
| 委托 | 支持 | 不支持 |
七、如何选择
选 ERC20Snapshot 还是 Votes,主要取决于是否需要委托投票,或更抽象地说,ERC20 代币所赋予的某种权利是否需要委托。
- 需要委托/投票 → ERC20Votes;
- 只需追踪历史余额、不需委托 → ERC20Snapshot。
现代 DAO 几乎都用 ERC20Votes,因为它能配合 OpenZeppelin Governor 开箱即用。
八、总结
- ERC20Votes = ERC20 + 委托投票 + 自动检查点,但不负责办投票;
- 必须先 delegate 给自己才有票(Gas 优化的怪癖);
- 检查点按账户记录投票权历史,转账/委托/铸销自动触发;
- delegateBySig 用签名实现无 Gas 委托;
- ERC-6372 时钟让计时可选区块号或时间戳;
- 需要委托就用 Votes,否则用 Snapshot。
九、动手练习项目:委托治理代币 DelegateGov
项目目标
实现一个 ERC20Votes 代币,亲手验证”委托给自己才有票""委托转移投票权""getPastVotes 历史查询”,并体验 ERC-6372 时钟模式。部署到 Sepolia。
合约要求
1. DelegateGovToken.sol
- 继承 OpenZeppelin
ERC20、ERC20Permit、ERC20Votes(v5); - 实现
clock()和CLOCK_MODE():做两个版本——一个返回时间戳模式、一个返回区块号模式(用编译开关或两个合约对比); - mint 给测试地址。
2. 配套测试场景(可不写额外合约,全在测试里验证)
测试要求(Foundry,重点用 vm.roll/vm.warp)
test_NoVotesWithoutDelegation:mint 100 给 A,未委托时getVotes(A) == 0;test_SelfDelegate:Adelegate(A)后getVotes(A) == 100;test_DelegateToOther:A 委托给 B,getVotes(B) == 100、getVotes(A) == 0,A 仍持有代币;test_TransferMovesVotes:A、B 都自委托,A 转 30 给 B,投票权随之变化;test_GetPastVotes:自委托后推进若干区块/时间,再转账,断言getPastVotes(A, 过去时间点)返回历史值;test_DelegateBySig:构造 EIP-712 签名,第三方提交delegateBySig,验证委托生效、nonce 递增、重放被拒;test_ClockMode:断言CLOCK_MODE()返回值符合所选模式;时间戳模式下clock() == block.timestamp,区块号模式下== block.number。
Sepolia 部署与验证步骤
- 部署 DelegateGovToken,mint 给你的两个地址;
- 在 Etherscan 调
getVotes确认未委托为 0; delegate(自己)后再读getVotes变为余额;- 委托给第二个地址,观察投票权转移;
- 推进一些区块后调
getPastVotes读历史; - 读
CLOCK_MODE()确认时钟模式字符串。
进阶挑战(可选)
- 对比时间戳模式 vs 区块号模式在 Sepolia(出块约 12 秒)上对”投票延迟/投票期”的实际影响;
- 写注释回答:为什么”必须先委托给自己”能省 Gas?如果默认所有持币者都有投票权,转账时要多做什么?