研究如何利用 Solidity 新操作码 Prevrandao 获取随机数

研究如何利用 Solidity 新操作码 Prevrandao 获取随机数

让我们回顾一下,弄清楚自合并以来发生了什么变化。这次升级终于给以太坊带来了一个新的共识机制。取代了旧的工作量证明,现在通过 权益证明(Proof of Stake) 来产生区块。

工作量证明通过区块哈希值和一个叫做挖矿的过程找到共识。在以太坊合并之前,矿工通常会使用GPU寻找特定的区块哈希值。这个过程是不可预测的,只能用暴力解决。因此,如果你找到一个合适的哈希值,你就证明了一些工作。

现在,你证明了一些权益而不是工作。矿工现在被称为验证者,每个人都必须拿出32ETH作为押注。新区块由注”正确“32ETH赌注的验证者提出。

为什么权益证明需要随机性?

但是,为什么以太坊甚至需要随机性来证明其权益协议?你可能天真地设计它以便以”循环排班“(round-robin)的方式,每个验证人将按照预先定义的顺序一个接一个地被选中。

img

但是,这样一种可预测的方式伴随着一系列潜在的攻击。

  • 拒绝服务(DOS):如果你事先知道谁将是下一个区块的提议者,你可以更容易地对被选中的验证者逐一发动DOS攻击。如果顺序是随机的,你就没有那么多时间来计划你的攻击。

  • 自私的验证人注册:人们可以尝试注册特别有利的验证者,使其更早地被选中,并通过游戏机制来获得更多的奖励。

  • 贿赂:你也可以尝试提前贿赂验证人,让他们对你可能感兴趣的区块进行审查,例如审查一些特定的交易或根本不生产区块。

  • 双花:也许最关键的攻击之一可能是双重花费攻击。如果你能提前预测顺序,那么通过贿赂和简单地自己拥有一连串的验证者,就更容易计划这样的攻击。而且你会清楚地知道你能在哪个区块尝试你的双重花费攻击。

贿赂备忘录

进入Randao

那么以太坊中的随机性是如何产生的呢?简单地说,你让每个验证者在一个给定的 epoch(时段) 中预先承诺(pre-commit)一个就在本地计算的随机数。最终的随机数是每个验证者的本地随机数通过XOR(异或)组合而成。XOR的组合确保了在一个 epoch 中,即使只有验证者的提交的诚实计算的随机数会用来生成一个新的未被操纵的随机数。

一个epoch 由32个 slot(时隙)组成,因此有32个潜在的验证者,假设所有的验证者都在线并提出一个新的区块,即有32个区块。

从技术上讲,验证者甚至不再需要计算一个本地随机数。相反,他们使用BLS签名,用自己的私钥签署当前的 epoch 号。然后,该签名被散列并作为随机数使用。这简化了协议,同时也允许多方验证者,这在承诺方案中做不到。

更新 Randao

一旦一个epoch (时段)结束,新计算的随机性将被用来确定下一个epoch的验证者。技术上来说,实际上第二个epoch要给验证者时间来学习和准备他们的新角色,但这是一个我们可以忽略的细节。

Randao

而每次”混洗(shuffle)“基本上都是:

  1. 在当前 epoch 号上签名。
  2. 计算签名的哈希值。
  3. XOR 之前的的随机值计算新的随机值为哈希值

像这样更新Randao的随机值有几个很好的特性:

  1. 哈希值确保了所有可能的随机值的均匀分布 => 对所有可能的结果的公平性。
  2. XOR确保每一个比特都受到影响,这意味着在一个epoch中只有一个真正的随机值会使最后的结果真正随机。
  3. 在epoch号上签名(而不是slot时隙号)会稍微减少对未来Randao epoch的影响。为什么?

    1. 想象一下,我是一个epoch中的最后一个验证者。我可以选择披露我的签名,从而更新Randao,或者不披露签名,从而保持之前的Randao值。总共有两种可能的结果,我们把它们称为结果A和B。

      1. 结果A会给我下一个epoch,这个epoch以我自己的验证器V1结束,然后是我自己的验证器V0。

      2. 结果B将给我下一个epoch,这个epoch以我自己的验证器V0结束,然后是我自己的验证器V1。

    2. 结果A和B对随机性的影响是一样的,所以不管我的选择如何,我都会有同样的影响。

可偏向性

我们已经谈到了可偏向性问题。从本质上讲,它归结为最后的揭示者问题。

  • 你不能直接改变随机数。
  • 但你可以选择不在你的slot中的数据上签名。

如果你不在你的slot中的数据上签名呢?那么没有人拥有私钥,所以没有办法得到这些数据。而在这种情况下,该slot的Randao更新被简单地跳过。

这种攻击的代价是什么?

区块奖励大约是0.044 ETH(取决于ETH通膨率)。不签署就意味着失去了这个奖励。

ultrasound.money 有一个很好的可视化通膨与交易费用的对比。自EIP-1559以来的交易费用被销毁,所以如果一个区块的交易费用 > 区块奖励,ETH实际上是通缩的。

ultrasound.money

现在,如果这刚好是一个epoch的最后一个slot,那么最终的随机值将是倒数第二个slot的随机值。当然,攻击者甚至可以在一个epoch的最后一个slot中控制多个验证器。因此,如果一个攻击者控制了一个epoch的最后四个验证器,在这四个slot中的每一个,他都可以选择签署或不签署。总共给他2^4=16个可能的总随机值来选择。

换句话说,在一个epoch的最后,每一个被控制的验证器,攻击者对最终的输出都有一个比特的影响

Randao Biasing

通过VDF提高偏倚性

所以我们可以看到,Randao 随机性在某种程度上是有可偏向性的。好消息是,这不会破坏以太坊协议的安全性。所以对于协议本身来说,这已经是不错的了。

但无论如何,可以在此基础上进行改进吗?特别是由于像我们这样的Dapp开发者可能想要一个更安全、更不容易产生偏差的随机数。

以太坊正在计划的针对这最后一个揭示者问题的改进是基于可验证延迟函数(VDF)。它依赖于在比其他人快几个数量级的时间内,计算一些任何人都不执行的函数。一种方法是强制执行顺序计算,例如通过对一个数字进行顺序平方。你可以在这里 看看 Justin Drake关于这个的演讲。

然后,VDF的输出将被用于更新Randao值。因此,即使是最后的披露者也不会在决定签署或不签署之前就知道VDF的输出。

在https://www.vdfalliance.org/,已经对此进行了一些研究。但要真正实现这一点,还需要相当长的时间。目前还没有这方面的计划。

EIP-4399: 一个新的 Prevrandao 操作码

现在了解了Randao 的工作原理后,请向EIP-4399和一个新的操作码 "prevrandao "打招呼。它是在巴黎网络升级中加入的。

为了向后兼容,旧的block.difficulty操作码曾经给你当前区块的工作量证明难度,现在会返回prevrandao值。因为现在的难度操作码在权益证明中已经没有意义了,像这样重新使用它是为旧合约增加向后兼容性的一个好方法。

Chat GPT Tweet Randao

那么prevrandao到底返回了什么?嗯,这个名字让人很清楚。你将从上次Randao更新中获得Randao值。这意味着它在有人实际更新Randao及产生最新的区块(slot)后获得。

为什么不是来自当前区块的新Randao?因为在区块执行期间,新的Randao值还不知道。所以为了安全起见,请记住这一点,在执行的时候,是前一轮的值是已知的。

如何使用当前的Prevrandao

那么说了这么多,如何才能真正以安全的方式使用它prevrandao?有很多事情需要考虑,在很多情况下,你最好暂时不要使用它,直到有更好的方法出现

截至目前,Solidity 还不支持block.prevrandao,但已经有一个开放的PR。所以现在你必须使用block.difficulty

编辑更新:Solidity 0.8.18 版本已经添加了对 block.prevrandao 的支持。

首先是显而易见的,过去的随机性是已知和可预测的。那么我们应该怎么做,才能选一个的未来prevrandao值。

多远的未来?这就是问题的关键所在,也是在EVM中需要进一步支持的地方。EIP-4399建议如下:

  • 跳过至少有未来四个epoch, 一个新的epoch确保了一组新的验证者。而且,特别是四个epoch确保网络将错过至少一个提案,进一步降低了Randao可预测性。

    译者备注:如果验证者作弊等,其需要等待 4 个 epoch 才能撤出其质押金。在这 4 个 epoch 之内,就有可能面临举报和罚没。

  • 一个不靠近epoch开始的slot。想象一下,你在epoch的第4个 slot 中使用验证器。在epoch运行前大约6分钟,就知道谁将是新epoch的验证者。攻击者可以利用这段时间来试图贿赂或攻击这4个验证者。这样他就可以提前获得有关随机性的知识,同时对随机性产生影响,在2^4=16种不同的结果中进行选择。

    备注:一个 epoch 包括32 个slot , 每个slot 间隔12秒,一个 epoch的时间是6.4分钟。

不幸的是,EVM目前甚至不允许访问当前的epoch号。所以我们能做的就是用区块高度来近似它。请记住,一个slot可以是空的,并不产生一个块。这意味着如果我们等待128个区块,我们至少可以保证等待四个完整的epoch,所以我们就这样做。

现在有两种方法可以实现它。

想法1:要求将来的区块号 block >= n

我们可以强制要求使用高于或等于玩游戏的区块的prevrandao值。

  1. 在第一笔交易中,你将确定要使用的区块编号。

  2. 在第二笔交易中,你将等待,直到区块高度过去,然后进行游戏。

当然,这种方法允许验证者审查第二笔交易,并试图暂停(扣留)到他们有利的 prevrandao(block.difficulty)数值的时刻。

mapping (address => uint256) public gameWeiValues;
mapping (address => uint256) public blockNumbersToBeUsed;

function playGame() public {
    if (!blockNumbersToBeUsed[msg.sender]) {
        // first run, determine block number to be used                          
        blockNumbersToBeUsed[msg.sender] = block.number + 128;
        gameWeiValues[msg.sender] = msg.value;
        return;
    }

    require(block.number >= blockNumbersToBeUsed[msg.sender], "Too early");

    uint256 randomNumber = block.difficulty; // block.prevrandao

    if (randomNumber != 0 || randomNumber % 2 == 0) {
        uint256 winningAmount = gameWeiValues[msg.sender] * 2;
        (bool success, ) = msg.sender.call{value: winningAmount}("");
        require(success, "Transfer failed.");
    }

    blockNumbersToBeUsed[msg.sender] = 0;
    gameWeiValues[msg.sender] = 0;
}

想法2:要求将来的区块号 ==n

另外,我们可以强制执行一个特定的块。那么我们就不会有验证者扣留交易的问题了。

  1. 在第一笔交易中,你将确定要使用的区块编号。

  2. 在第二笔交易中,你会等到确切的区块高度,然后进行游戏。

当然,这种方法有一个问题,就是你可能会错过区块,并且需要处理没有任何随机性的情况。

mapping (address => uint256) public gameWeiValues;
mapping (address => uint256) public blockNumbersToBeUsed;

function playGame() public {
    if (!blockNumbersToBeUsed[msg.sender]) {
        // first run, determine block number to be used                          
        blockNumbersToBeUsed[msg.sender] = block.number + 128;
        gameWeiValues[msg.sender] = msg.value;
        return;
    }

    require(block.number > blockNumbersToBeUsed[msg.sender], "Too early");
    require(block.number < blockNumbersToBeUsed[msg.sender], "Too late");

    uint256 randomNumber = block.difficulty; // block.prevrandao

    if (randomNumber != 0 || randomNumber % 2 == 0) {
        uint256 winningAmount = gameWeiValues[msg.sender] * 2;
        (bool success, ) = msg.sender.call{value: winningAmount}("");
        require(success, "Transfer failed.");
    }

    blockNumbersToBeUsed[msg.sender] = 0;
    gameWeiValues[msg.sender] = 0;
}

两个想法的优缺点:

想法1

优点

  • 它允许在区块n之后的任何时间玩游戏。

缺点

  • 它通过审查玩游戏的交易,让验证者对随机性有更大的影响,所以它被包含在后面的块中。

想法2

优点

  • 它没有给验证者更多的影响。

缺点

  • 它只允许在块n中玩游戏,如果你错过了,以后就没有办法得到这个值了。

如何使用未来的Prevrandao(n)

现在,如果prevrandao操作码可以用来获取过去的值,那就好得多了。这是一个可能的未来功能

所以,让我们假设有了这个功能。我们可以如何改变游戏?

  1. 我们仍然要确定要使用prevrandao值的区块。
  2. 然后我们简单地等待区块的到来,并将其检索出来。

这一次,如果我们错过了,也没有关系,因为我们可以访问旧的prevrandao值。

你可以关注以太坊 Magicians Thread,以了解任何潜在的未来EIP的更新。

// NOTE: below code is speculation on a possible future design

mapping (address => uint256) public gameWeiValues;
mapping (address => uint256) public blockNumbersToBeUsed;

function playGame() public payable {
    uint256 blockNumberToBeUsed = blockNumbersToBeUsed[msg.sender];

    if (blockNumberToBeUsed == 0) {
        // first run, determine block number to be used                          
        blockNumbersToBeUsed[msg.sender] = block.number + 128;
        gameWeiValues[msg.sender] = msg.value;
        return;
    }

    require(block.number >= blockNumberToBeUsed, "Too early");

    uint256 randomNumber = block.prevrandao(blockNumberToBeUsed);

    if (randomNumber != 0 || randomNumber % 2 == 0) {
        uint256 winningAmount = gameWeiValues[msg.sender] * 2;
        (bool success, ) = msg.sender.call{value: winningAmount}("");
        require(success, "Transfer failed.");
    }

    blockNumbersToBeUsed[msg.sender] = 0;
    gameWeiValues[msg.sender] = 0;
}

未来备忘录

但请记住,仍然开头提到的关于偏向性的所有事情仍然适用。一个验证者仍然可以引起重新混洗(re-shuffle)。

真正的长期解决方案是增加VDF

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO