文章详细介绍了ERC20快照技术,解决双花投票和重复领取空投的问题,并探讨了其实现机制和OpenZeppelin的解决方案。
ERC20 Snapshot 解决了双重投票的问题。如果投票的权重是由某人持有的代币数量决定的,那么恶意行为者可以使用他们的代币投票,然后将代币转移到另一个地址,再用那个地址投票,如此反复。如果每个地址都是一个智能合约,那么黑客可以在一笔交易中完成所有这些投票。一个相关的攻击是利用闪电贷获得大量治理代币,投票后再偿还闪电贷。声明空投时也存在类似的问题。可以使用他们的 ERC20 代币去声明空投,然后将他们的代币转移到另一个地址,再次声明空投。从根本上讲,ERC20 snapshot 提供了一种机制,以防止用户在同一交易中转移代币并重复使用代币的实用性。一开始,快照可能似乎是一个不可解决的问题。解决此问题的野蛮、或天真的解决方案是遍历 ERC20 的“balances”映射中的每个地址,然后将其复制到另一个映射中。由于无法在 ERC20 中本地迭代映射,因此程序员需要使用 可枚举映射 —— 具有跟踪所有键的数组的映射。可以想象,这个 O(n) 操作将会非常耗气。计算机科学中有一句话是“计算机科学中的每个问题都可以通过另一层间接性来解决”,而这就是 ERC20 snapshot 解决问题的方式。
让我们以余额映射为例。这里有一个有缺陷的 solidity 解决方案,但朝着正确的方向迈出了一步。
balances[snapshotNumber][user]
在这种情况下,snapshotNumber
是一个从零开始的计数器,每次进行快照时增加一。回到我们的投票示例,我们在特定的时间点创建一个快照,让每个人按照自己的方式进行,然后再创建另一个快照。在投票时,我们使用之前的快照,因为当前快照仍然可以通过转移代币进行更改。这样,我们可以通过提供 snapshotNumber
和我们关心的快照时的地址来查询某人的余额。由于我们知道当前快照,balanceOf
只是最近快照的余额。哦,但是有一个问题!每次进行快照时,每个人的余额都被设置为零!可以通过一些会计方法来解决这个问题 —— 只需跟踪用户最后进行交易的快照,但当工程师试图覆盖所有边界情况时,这很快会变得复杂。
这就是 OpenZeppelin 实现它的方式。 代码 每个余额存储一个结构体
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);
}
在用户余额内部,我们存储一个结构体,其中包含一个 ids 和一个 values 的数组。ids 数组是单调递增的快照 id,而 values 是当该 id 为活动快照时的余额。
这是快照函数。它简单地递增当前快照 id。
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
当用户在新的快照中进行转移时,会调用 _beforeTokenTransfer
钩子,其中包含以下代码。接收者和发送者都调用了 _updateAccountSnapshot
。
// 在修改值之前更新余额和/或总供应快照。这是在执行 _mint、_burn 和 _transfer 操作时执行的 _beforeTokenTransfer 钩子中实现的。
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 语句将为真。在快照数组内部,会附加当前余额。由于这是在 _beforeTokenTransfer
钩子中调用,因此这反映了余额在更改之前的状态。因此,一旦快照 ID 增加,快照交易后发生的任何转移将保存余额_在_事务发生之前,并将其存入数组。这有效地“冻结”了每个人的当前余额,因为快照后发生的任何转移都导致“旧”值被存储。如果发生两个快照,但一个地址在这些快照期间没有进行交易怎么办?在这种情况下,快照 ids 将不是连续的。因此,我们不能通过“ids[snapshotId]”访问帐户在快照时的余额。而是使用 二分查找 来查找用户请求的快照 id。如果找不到 id,则使用上一个相邻快照的值。例如,如果我们想知道用户在快照 5 下的余额,但他们在快照 3 和 4 期间没有转移代币,我们会查看快照 2。
读者可能注意到,结构体 Snapshots 似乎具有泛化过度的变量名称,例如 ids 和 values。它不应该命名为“余额”以更准确吗?ERC20 Snapshot 使用相同策略跟踪总供应,因此变量名称捕获到同一结构同时用于跟踪用户余额和总供应。仅有 mint 和 burn 改变总供应,因此当这些函数被调用时,存储总供应的结构会检查快照是否已经改变,然后更新这些值。请注意,历史许可值未被快照。
常规转移的成本更高,因为我们需要检查用户的 ids 中的最后 ID 是否与当前快照匹配,如果不匹配,则添加一个新的 ID。向 ids 和 values 数组追加将产生两个额外的 SSTORE。当发生新快照时,第一个转移到或从地址的交易将会更昂贵。但第二个交易的成本将与常规 ERC20 代币的转移大致相同。
如果有人进行闪电贷并在同一交易中创建快照,他们可以人为地增加他们的投票权。如果代币可以以低利率借出,并且攻击者知道下一个快照何时发生,他们可以在快照前借入代币以实现类似的目的。然而,闪电贷不会成为增加投票权的可行方式,因为他们需要在一个_单独的_快照交易中保持高余额。
这只是某个地址的余额除以总供应,所有这些都在特定的快照时刻。最早发布于 2023 年 2 月 22 日
- 原文链接: rareskills.io/post/erc20...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!