ERC20 Snapshot解决双重投票问题

  • SmileBits
  • 更新于 2024-04-17 22:50
  • 阅读 166

如果投票是根据某人持有的代币数量来衡量的,那么恶意行为者就可以使用他们的代币进行投票,然后将代币转移到另一个地址,用该地址进行投票,依此类推。如果每个地址都是一个智能合约,那么黑客可以在一笔交易中完成所有这些投票。一个相关的攻击是使用闪贷获取一堆治理代币,进行投票,然后返还闪贷。领取空投也存在类似

如果投票是根据某人持有的代币数量来衡量的,那么恶意行为者就可以使用他们的代币进行投票,然后将代币转移到另一个地址,用该地址进行投票,依此类推。如果每个地址都是一个智能合约,那么黑客可以在一笔交易中完成所有这些投票。一个相关的攻击是使用闪贷获取一堆治理代币,进行投票,然后返还闪贷。 领取空投也存在类似的问题。人们可以使用 ERC20 代币领取空投,然后将代币转移到另一个地址,然后再次领取空投。 从根本上来说,ERC20 快照提供了一种机制来防御用户在同一交易中转移代币和重复使用代币效用。

Openzeppelin 解决方案

这是Openzeppelin的代码链接(code)

关键数据结构,每个用户都存储了对应Snapshot时的token数量

struct Snapshots {
        uint256[] ids;
        uint256[] values;
    }

    mapping(address => Snapshots) private _accountBalanceSnapshots;
    Snapshots private _totalSupplySnapshots;

    // Snapshot ids increase monotonically, with the first value being 1. An id of 0 is invalid.
    Counters.Counter private _currentSnapshotId;

在用户余额中,我们存储一个包含 id 和值数组的结构。 ids 数组是一个单调递增的快照 id,其值是该 id 为活动快照时的余额。

进行一次快照

这里是快照功能。它只是增加当前快照 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更新了该account在当前snapshotsId时的权重

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 语句将为 true。在快照数组内,将附加当前余额。因为这是在 _beforeTokenTransfer 挂钩中调用的,所以这反映了更改之前的余额。

因此,一旦快照 ID 增加,快照事务之后发生的任何传输都将存储事务发生之前的余额并将其存储到数组中。这有效地“冻结”了每个人的当前余额,因为快照之后发生的任何传输都会导致“旧”值被存储。 如果发生两个快照,但某个地址在这些快照期间没有进行交易,会发生什么情况?在这种情况下,快照 ID 将不连续。 因此,我们无法通过执行“ids[snapshotId]”来访问快照上的帐户余额。相反,二分搜索用于查找用户请求的快照 ID。如果未找到 id,则我们使用之前的相邻快照值。例如,如果我们想知道用户在快照 5 的余额,但他们在快照 3 和 4 期间没有转移代币,我们会查看快照 2。

function _valueAt(uint256 snapshotId, Snapshots storage snapshots) private view returns (bool, uint256) {
        require(snapshotId > 0, "ERC20Snapshot: id is 0");
        require(snapshotId <= _getCurrentSnapshotId(), "ERC20Snapshot: nonexistent id");

        // When a valid snapshot is queried, there are three possibilities:
        //  a) The queried value was not modified after the snapshot was taken. Therefore, a snapshot entry was never
        //  created for this id, and all stored snapshot ids are smaller than the requested one. The value that corresponds
        //  to this id is the current one.
        //  b) The queried value was modified after the snapshot was taken. Therefore, there will be an entry with the
        //  requested id, and its value is the one to return.
        //  c) More snapshots were created after the requested one, and the queried value was later modified. There will be
        //  no entry for the requested id: the value that corresponds to it is that of the smallest snapshot id that is
        //  larger than the requested one.
        //
        // In summary, we need to find an element in an array, returning the index of the smallest value that is larger if
        // it is not found, unless said value doesn't exist (e.g. when all values are smaller). Arrays.findUpperBound does
        // exactly this.

        uint256 index = snapshots.ids.findUpperBound(snapshotId);

        if (index == snapshots.ids.length) {
            return (false, 0);
        } else {
            return (true, snapshots.values[index]);
        }
    }

Token总量也以同样的方式跟踪

攻击方法

  • 如果有人提取闪电贷并在同一笔交易中创建快照(在获得代币,还款前创建),他们就可以人为地夸大自己的投票权。
  • 如果可以以低利率借用代币,并且攻击者知道下一次快照何时发生​​,他们可以借用导致快照的代币来完成类似的事情。(同样也是在快照前借到币,快照后再还币,还币转移过程中就记录了之前的代币数量)
点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
SmileBits
SmileBits
智能合约安全审计