ERC20 Snapshot 解决了双重投票的问题。
如果选票权重由某人持有的代币数量决定,那么恶意行为者就可以使用其代币进行投票,然后将代币转移到另一个地址,再用该地址进行投票,以此类推。如果每个地址都是一个智能合约,那么黑客就可以在单笔交易中完成所有这些投票。一种相关的攻击方式是使用闪电贷获取大量治理代币,进行投票,然后再偿还闪电贷。
领取空投时也存在类似的问题。一个人可以使用其 ERC20 代币领取空投,然后将其代币转移到另一个地址,接着再次领取空投。
从根本上说,ERC20 Snapshot 提供了一种机制,以防止用户在同一笔交易中转移代币并重复利用代币的效用。
乍一看,快照似乎是一个棘手的问题。暴力或朴素的解决方案是遍历 ERC20 中 balances 映射里的每一个地址,然后将它们复制到另一个映射中。在 ERC20 中无法原生遍历映射,因此编码人员必须使用 enumerable map —— 一种带有数组以跟踪所有键的映射。
可以想象,这种 O(n) 操作将消耗极其大量的 gas。
计算机科学中有一句名言:“计算机科学中的任何问题都可以通过增加一个间接层来解决”,这正是 ERC20 快照解决该问题的方式。
Efficient but Naive Solution
让我们以 balances 映射为例。以下是一个存在缺陷的 solidity 解决方案,但方向是正确的。
balances[snapshotNumber][user]
在这种情况下,snapshotNumber 是一个计数器,从零开始,每次执行快照时递增一。
回到我们的投票示例,我们在某个特定时间点创建一个快照,让每个人继续他们的操作,然后再创建另一个快照。在投票时,我们使用上一个快照,因为当前的快照仍然可以通过转移代币来改变。
这样一来,我们可以通过提供我们关注的快照时的 snapshotNumber 及其地址来查询某人的余额。既然我们知道当前的快照,balanceOf 也就仅仅是最近一次快照时的余额。
啊,但是有一个问题!每次我们进行快照时,每个人的余额都会被重置为零!可以通过一些记账方式来解决这个问题 —— 只需跟踪用户进行交易的最后一次快照即可,但当工程师试图覆盖所有的边界情况时,这很快就会变得非常复杂。
OpenZeppelin solution
这是 OpenZeppelin 的实现方式。code
每个余额存储一个 struct
struct Snapshots {
uint256[] ids;
uint256[] values;
}
mapping(address => Snapshots) private _accountBalanceSnapshots;
function balanceOfAt(address account, uint256 snapshotId) public view virtual returns (uint256) {
(bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]);
return snapshotted ? value : balanceOf(account);
}
在用户余额内部,我们存储了一个 struct,其中包含 ids 和 values 数组。ids 数组是一个单调递增的快照 ID 列表,而 values 则是该 ID 作为激活快照时的余额。
Taking a snapshot
这是快照函数。它仅仅递增了当前的快照 ID。
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
当用户在新的快照中进行转账时,会调用 _beforeTokenTransfer hook,其代码如下。
接收方和发送方都会调用 _updateAccountSnapshot。
// Update balance and/or total supply snapshots before the values are modified. This is implemented
// in the _beforeTokenTransfer hook, which is executed for _mint, _burn, and _transfer operations.
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
super._beforeTokenTransfer(from, to, amount);
if (from == address(0)) {
// mint
_updateAccountSnapshot(to);
_updateTotalSupplySnapshot();
} else if (to == address(0)) {
// burn
_updateAccountSnapshot(from);
_updateTotalSupplySnapshot();
} else {
// transfer
_updateAccountSnapshot(from);
_updateAccountSnapshot(to);
}
}
这是被调用的 _updateAccountSnapshot 函数
function _updateAccountSnapshot(address account) private {
_updateSnapshot(_accountBalanceSnapshots[account], balanceOf(account));
}
它随后会调用 _updateSnapshot。定义如下
function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private {
uint256 currentId = _getCurrentSnapshotId();
if (_lastSnapshotId(snapshots.ids) < currentId) {
snapshots.ids.push(currentId);
snapshots.values.push(currentValue);
}
}
因为 currentId 刚刚被递增,if 语句的条件将为真。在 snapshots 数组中,当前的余额将被追加进去。因为这是在 _beforeTokenTransfer hook 中被调用的,所以它反映的是发生改变之前的余额。
因此,一旦快照 ID 增加,在快照交易之后发生的任何转账都将存储交易发生之前的余额,并将其存储到数组中。这有效地“冻结”了每个人的当前余额,因为在快照之后发生的任何转账都会导致“旧”值被存储下来。如果发生了两次快照,但某个地址在这两次快照期间没有进行交易,会发生什么?在这种情况下,快照 ID 将是不连续的。
正因为如此,我们不能通过执行 ids[snapshotId] 来访问某个账户在某次快照时的余额。相反,我们使用 binary search 来寻找用户请求的快照 ID。如果找不到该 ID,那么我们就使用前一个相邻快照的值。例如,如果我们想知道某个用户在快照 5 时的余额,但他们在快照 3 和 4 期间没有转移代币,那么我们将查看快照 2。
Total supply is tracked the same way
读者可能会注意到,struct Snapshots 的变量名(如 ids 和 values)似乎过于通用。为了更准确,它难道不应该被命名为 balance 吗?
ERC20 Snapshot 使用相同的策略来跟踪总供应量,因此这些变量名体现了同一个 struct 既被用于跟踪用户余额,也被用于跟踪总供应量。
只有 mint 和 burn 会改变总供应量,因此当调用这些函数时,会在更新这些值之前检查存储总供应量的 struct,以查看快照是否发生了变化。
请注意,历史的 allowance 值不会被快照。
Added gas cost
常规转账的成本会更高,因为我们要检查用户 ids 中的最后一个 ID 是否与当前快照匹配,如果不匹配则添加一个新的 ID。附加到 ids 和 values 数组将产生两次额外的 SSTORE。当新的快照发生时,对某个地址进行的第一次转入或转出操作将更加昂贵。但是第二次交易的成本与常规 ERC20 代币转账的成本大致相同。
Getting hacked
如果有人提取闪电贷并在同一笔交易中创建快照,他们就可以人为地夸大其投票权。如果能以低利率借入代币,并且攻击者知道下一次快照何时发生,他们可以在快照之前借入代币来完成类似的操作。然而,闪电贷将不再是夸大投票权的有效方法,因为他们需要在一个独立的快照交易期间保持较高的余额。
Tallying votes
这仅仅是某个地址的余额除以总供应量,且两者都处于某一个特定的快照状态。
最初发布于 2023 年 2 月 22 日