ERC-20 Snapshot 详解:防止重复投票的余额快照机制
目录
- 一、ERC-20 Snapshot 是什么
- 二、它解决什么问题
- 三、朴素方案为什么不行
- 四、OpenZeppelin 的实现思路
- 五、快照如何工作
- 六、二分查找
- 七、总供应量追踪
- 八、Gas 开销分析
- 九、残留的安全隐患
- 十、为什么逐渐被 ERC20Votes 取代
- 十一、动手练习项目:快照投票代币 SnapshotBallot
一、ERC-20 Snapshot 是什么
ERC-20 Snapshot 是一种机制,通过给代币余额创建历史检查点来”解决重复投票问题”。它防止用户在一笔交易里多次复用代币的效用,也防止用闪电贷临时抬高投票权。
二、它解决什么问题
2.1 重复投票攻击
恶意用户可以:用自己的代币投票 → 把代币转给另一个地址 → 再用同一批代币投票。由于智能合约能在一笔交易里原子地执行多个动作,攻击者能在一笔交易里完成无数次投票。
2.2 闪电贷攻击
攻击者可以闪电贷借入治理代币 → 投票 → 同一区块内归还。空投也能被类似攻击:领取 → 转走 → 再领取。
ERC-20 Snapshot 提供了一种防御机制,阻止用户在同一笔交易里转移代币并重复使用代币效用。
核心思路:投票权不看”现在的余额”,而看”某个历史快照时刻的余额”——历史是固定的,转来转去也改不了过去。
三、朴素方案为什么不行
最直接的想法:拍快照时遍历 balances 里的每个地址,把余额复制到一个快照 mapping。
问题:这需要一个可枚举的 map,而且是 O(n) 复杂度——地址越多 Gas 越爆炸,链上完全不可行。
四、OpenZeppelin 的实现思路
4.1 多一层间接的核心思想
计算机科学名言:“任何问题都可以通过增加一个间接层来解决。” OpenZeppelin 不复制所有余额,而是增量地记录余额变化——只在余额真正改变时才记一笔。
4.2 Snapshots 结构体
struct Snapshots {
uint256[] ids; // 单调递增的快照 ID
uint256[] values; // 对应每个快照 ID 时的余额
}
mapping(address => Snapshots) private _accountBalanceSnapshots;
每个地址维护两个平行数组:ids 存快照编号,values 存那个编号时刻的余额。
4.3 balanceOfAt 查询历史余额
function balanceOfAt(address account, uint256 snapshotId) public view returns (uint256) {
(bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]);
return snapshotted ? value : balanceOf(account);
}
逻辑:如果该快照记录过,返回记录值;否则返回当前余额(说明该地址自那以后没动过,当前值就是历史值)。
五、快照如何工作
5.1 拍快照只是自增计数器
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
拍快照只是把一个计数器加 1,不复制任何余额——真正的余额记录是**惰性(lazy)**发生的。
5.2 _beforeTokenTransfer 钩子
快照创建后,发生转账/铸造/销毁时会触发钩子:
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
super._beforeTokenTransfer(from, to, amount);
if (from == address(0)) { // 铸造
_updateAccountSnapshot(to);
_updateTotalSupplySnapshot();
} else if (to == address(0)) { // 销毁
_updateAccountSnapshot(from);
_updateTotalSupplySnapshot();
} else { // 转账
_updateAccountSnapshot(from);
_updateAccountSnapshot(to);
}
}
更新函数的关键:
function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private {
uint256 currentId = _getCurrentSnapshotId();
if (_lastSnapshotId(snapshots.ids) < currentId) {
snapshots.ids.push(currentId);
snapshots.values.push(currentValue); // 存的是"转账前"的余额
}
}
5.3 余额”冻结”机制
关键洞察:_updateSnapshot 在 _beforeTokenTransfer 里执行,记录的是余额被改变之前的值。
一旦快照 ID 自增,之后任何转账都会先触发钩子、记下旧余额——这等于把所有人在快照那一刻的余额”冻结”保存了。如果某地址在快照 3 和 4 期间从没交易过,它的 ids 数组里就不会有 3 和 4 这两个编号(省 Gas)。
六、二分查找
因为快照 ID 可能不连续(地址不是每次快照都交易),不能直接用数组下标索引,而要用二分查找定位对应的历史余额。如果查的精确 ID 不存在,就用最近的上一个值。
例子:查询某地址在快照 5 的余额,但它只在快照 2 和 6 交易过——系统返回快照 2 的值(因为从快照 2 到 5 它没动过,余额一直是快照 2 的值)。
七、总供应量追踪
同一个 Snapshots 结构体也用来追踪总供应量(所以字段名用了通用的 ids/values)。只有铸造和销毁会改变总供应量,这些操作检查快照是否变化并更新。
⚠️ 注意:历史授权额度(allowance)不被快照——只快照余额和总供应。
八、Gas 开销分析
普通转账变贵了,因为系统要检查”最后一个 ID 是否等于当前快照 ID”,不等就追加新条目(追加要 2 次额外 SSTORE)。
影响分布:
- 快照后第一笔转账:最贵(要追加新的 id 和 value);
- 后续转账:接近普通 ERC20 成本(同一快照内不再追加);
- 无活动的地址:零额外开销。
九、残留的安全隐患
- 闪电贷局限:如果有人在同一笔交易里闪电贷 + 拍快照,理论上能抬高投票权。但闪电贷不实用——要维持抬高的投票权,代币必须跨多个快照一直被借着,成本极高;
- 低息借币风险:如果攻击者能廉价借币且知道下次快照时间,可以在快照前囤积。但要跨多个独立快照交易维持仓位,经济上不划算。
十、为什么逐渐被 ERC20Votes 取代
Snapshot 需要显式调用 _snapshot() 来拍快照,且不支持委托投票。ERC20Votes(下一篇)用 checkpoint 自动在转账/委托时记录,并支持把投票权委托给别人,功能更强,已成为主流。本篇仍重要,因为它清晰展示了”惰性记录 + 二分查找”这个经典模式。
十一、动手练习项目:快照投票代币 SnapshotBallot
项目目标
实现一个支持快照的治理代币 + 一个用快照余额计票的提案投票合约,亲手验证”转账无法重复投票”。部署到 Sepolia。
合约要求
1. SnapshotToken.sol
- 继承 OpenZeppelin
ERC20+ERC20Snapshot(或在新版用ERC20+ 自实现快照逻辑,理解原理); mint给若干测试地址;snapshot() external onlyOwner returns (uint256):暴露_snapshot();- 重写
_beforeTokenTransfer/_update接驳快照逻辑。
2. Ballot.sol(用快照计票的投票合约)
createProposal(string desc) returns (uint256 proposalId):创建提案时记录当前 snapshotId(调 token.snapshot());vote(uint256 proposalId, bool support):投票权 =token.balanceOfAt(msg.sender, proposal.snapshotId),记录已投防止重复(mapping proposalId => voter => bool);result(uint256 proposalId) view returns (uint256 forVotes, uint256 againstVotes)。
测试要求(Foundry)
test_VoteWeightFromSnapshot:A 有 100 票,createProposal 拍快照后 A 把代币全转给 B,A 投票仍按 100 计、B 按快照时的 0 计——证明转账不改变历史投票权;test_DoubleVoteBlocked:A 投票后再投应 revert;test_BalanceOfAt:在快照 1 后转账,断言balanceOfAt(addr, 1)返回旧值、balanceOf返回新值;test_BinarySearchGap:地址只在快照 2、6 交易,查询快照 4 返回快照 2 的值;test_TotalSupplyAt:mint/burn 后totalSupplyAt正确;test_FlashLoanIneffective:模拟同笔交易借币投票,说明需跨多快照才有效(注释论证)。
Sepolia 部署与验证步骤
- 部署 SnapshotToken,mint 给 2-3 个地址;
- 部署 Ballot,createProposal(触发快照);
- 投票后把代币转走,再让接收方投票,在 Etherscan 上读 result 验证票数符合快照;
- 用
balanceOfAt读不同快照 ID 的历史余额对照。
进阶挑战(可选)
- 不用 OZ,自己用 Snapshots 结构体 + 二分查找从零实现,理解每次转账的 SSTORE 开销;
- 写注释回答:为什么 allowance 不快照?如果快照 allowance 会带来什么问题?