ERC-20 Snapshot 详解:防止重复投票的余额快照机制

理解快照如何记录历史余额、惰性更新与二分查找,抵御闪电贷治理攻击

7 分钟阅读
ERC-20 Snapshot 详解:防止重复投票的余额快照机制

ERC-20 Snapshot 详解:防止重复投票的余额快照机制

目录


一、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)

  1. test_VoteWeightFromSnapshot:A 有 100 票,createProposal 拍快照后 A 把代币全转给 B,A 投票仍按 100 计、B 按快照时的 0 计——证明转账不改变历史投票权;
  2. test_DoubleVoteBlocked:A 投票后再投应 revert;
  3. test_BalanceOfAt:在快照 1 后转账,断言 balanceOfAt(addr, 1) 返回旧值、balanceOf 返回新值;
  4. test_BinarySearchGap:地址只在快照 2、6 交易,查询快照 4 返回快照 2 的值;
  5. test_TotalSupplyAt:mint/burn 后 totalSupplyAt 正确;
  6. test_FlashLoanIneffective:模拟同笔交易借币投票,说明需跨多快照才有效(注释论证)。

Sepolia 部署与验证步骤

  1. 部署 SnapshotToken,mint 给 2-3 个地址;
  2. 部署 Ballot,createProposal(触发快照);
  3. 投票后把代币转走,再让接收方投票,在 Etherscan 上读 result 验证票数符合快照;
  4. balanceOfAt 读不同快照 ID 的历史余额对照。

进阶挑战(可选)

  • 不用 OZ,自己用 Snapshots 结构体 + 二分查找从零实现,理解每次转账的 SSTORE 开销;
  • 写注释回答:为什么 allowance 不快照?如果快照 allowance 会带来什么问题?

💬 评论