Solidity智能合约中随机数的生成

  • Post author:
  • Post category:solidity

在智能合约开发中常常会用到随机数,例如抽奖、中签等通过随机数来选择winner的场景,之前在项目中也遇到了这个需求,本篇文章就聊聊solidity创建区块链上的随机数会有哪些问题以及目前常用的方法有哪些。

相关概念

想要了解Solidity智能合约中随机数的生成方法,首先需要了解几个相关的基本概念:

伪随机数:用确定性的算法计算出均匀分布的随机数序列,之所以称作“伪”,是因为生成器伪随机数的确定算法(伪随机数生成器,PRNG)是基于称为seed的初始值来生成随机数的,如果seed是相同的,那么生成的随机数也是相同的。因此我们想要更加不可预测的伪随机数时,通常是选用随机的seed作为初始值。

图灵机(Turing Machine):又称确定性图灵机(Deterministic Turing Machine),是英国数学家艾伦·图灵于1936年提出的一种将人的计算行为抽象化的数学逻辑,其更抽象的意义为一种计算模型,可以看做等价于任何有限逻辑数学过程的强大逻辑机器——(维基百科)。确定性图灵机指的是图灵机的下一个状态是由当前状态和行为(action)来唯一确定的,不会产生多种可能的状态。

Solidity智能合约生成随机数的一些限制

成本问题:如果在智能合约中进行复杂计算,会消耗大量gas,成本太高;

确定性问题:智能合约需要在多个节点上运行,因此我们需要solidity代码是确定性的,即在每个节点上运行都能获得确定性结果,不能因为硬件或者本地环境而产生偏差。

常用方法

接下来就介绍三种常见的solidity智能合约生成/使用随机数的方法,每种方法都各有利弊,实际使用还要看具体场景来选择。

使用区块变量

常见方法是使用block.timestampblock.difficulty ,例如创建一个Random函数:

function random(uint number) public view returns(uint) {
    return uint(keccak256(abi.encodePacked(block.timestamp,block.difficulty,  
        msg.sender))) % number;
}

详细分解一下上面的这个function:

  • 入参“number”:这个是控制随机数数值范围的,通过模计算(%)取余产生区间为[0, number]的随机数;
  • block.timestamp 代表调用random这个函数的时间;
  • block.difficulty 代表当前的挖矿难度,在以太坊中这个值会动态调整;
  • msg.sender 指的是调用这个函数的地址;
  • abi.encodePacked 对seed参数进行编码,solidity提供两种编码方法encode和encodePacked,前者对每一个参数进行32字节补齐,后者不进行补齐,直接将待编码参数连接起来,具体差别可参见solidity文档
  • keccak256 哈希算法,可以将任意长度的输入压缩成64位16进制的数,且哈希碰撞的概率近乎为0;

上面这个方法如果是用在一个抽奖的场景,参与抽奖者是不能控制这些seed参数的,能达到一个相对公平的目的,但是矿工是有可能作弊的,在solidity的官网上有这样一段注释:

矿工可以决定是否广播一个区块出去,即使他们挖出了一个区块,也可直接扔掉,因此他们可以持续尝试random函数,直至得到想要的结果再广播出去。当然,矿工会这样做的前提是有足够的的利益诱惑,例如可以获得一个很大的奖金池中的奖金,因此使用区块变量获取随机数的方法更适合于一些资产规模比较小的轻量级应用。

使用API/预言机获取随机数

既然使用区块链上的全局变量存在矿工作弊的风险,那么我们直接通过链下的第三方服务获取随机数,例如Random.org 就是一个通过JSON格式提供随机数服务,而且可以通过数字签名的方式来证明随机数是取自于Random.org并且未被篡改过。除了使用第三方服务,也可以由dapp开发商自己搭建一个链下服务提供随机数,这种在链上获取链下数据的场景通常是通过预言机的方式来实现。

当然这种方法也会有明显的安全风险,例如依赖第三方的话,与矿工作弊情况类似,同样存在第三方作弊或者因受贿的情形,即使是自己搭建的随机数服务,也可能因为故障等原因无法使用,对dapp的运行造成重大的损失。因此使用链下服务获取随机数的方法是强依赖于是否有一个可信又稳定的第三方服务,如果有,那么这个方法相对于使用区块链变量生成随机数的方法,随机数的不可预测性更强一些。

Chainlink VRF

Chainlink是建立在以太坊上的去中心化区块链预言机网络,主网2019年上线,为智能合约提供随机数服务也是其重要的功能之一。相较于API/预言机这种依赖于第三方随机数服务的方法,Chainlink VRF提供了一个链上去中心化的解决方案。

接下来先说说可验证随机数方程(Verifiable Random Function,VRF),在密码学中,VRF是一种公钥的伪随机函数,可提供其输出计算正确的证明,秘钥持有者可以计算函数值以及任何输入值的相关证明——维基百科。Chainlink VRF是为智能合约设计的可证明公平的、可验证的随机数生成器。

关于Chainlink VRF的设计原理可以参考官方的博客,本文主要介绍一下如何使用:

用到的工具:

IDE:Remix

Wallet:Metamask

Test Network:Rinkeby

Step1:关联Metamask,连接到Rinkeby之后,通过faucets.chain.link 获取测试LINK和ETH:

Step2:在Subscription Manager 页面创建一个Subscription账户,这个账户相当于一个预存款账户,使用Chainlink的VRF或者其他产品都需要支付费用,预先存一些LINK到Subscription账户就不需要每次请求随机数时都单独支付一次费用,而是直接从这个账户扣除,如下是操作具体步骤:

Confirm后Subscription创建成功:

转一些LINK到Subscription账户中:

转账成功后要添加consumer,即被授权可以使用Subscription账户中的LINK的智能合约,这个作为consumer的智能合约需要用户自己部署,官方提供了一个示例代码,通过remix编译部署合约,在deploy的时候选择Injected Web3 Environment,选择后会跟Metamask进行绑定,在deploy合约时输入之前创建的Subscription ID,不约部署成功后会返回合约地址:

部署的VRFv2Consumer.sol合约地址即为consumer address,现在添加consumer:

添加成功后,我们可以在Subscription页面看到相关的信息和历史操作记录:

Step3:至此就可以通过consumer合约从Chainlink VRF请求随机数:

通过Remix可以直接点击requestRandomWords()函数来向Chainlink VRF发送申请随机数的请求:

在Metamask中确认请求:

Chainlink预言机会将随机数返回到s_randomWords这个变量上,示例合约中是请求了2个随机数,因此s_randomWords是一个2个元素的数组,我们可以通过index 0或者1获取对应的随机数:

在Subscription页面可以看到对应的请求记录:

那么在实际应用中我们可以单独不说一个consumer合约专门去获取随机数,也可以将一些业务逻辑放到consumer合约中,融合成一个可以请求Chainlink VRF随机数的合约。

第三种方法虽然也是通过第三方服务获取随机数,但Chainlink是基于一个分布式网络,并非一个中心化的服务,在防作弊和稳定性上都会优于第二种方法,但每次申请随机数都需要支付费用,如果是高频使用随机数的场景,成本就是一个必须要考虑的因素了。

附:最后就Chainlink官网提供的consumer合约的示范代码做一些具体分析:

// SPDX-License-Identifier: MIT
// An example of a consumer contract that relies on a subscription for funding.
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

contract VRFv2Consumer is VRFConsumerBaseV2 {
  VRFCoordinatorV2Interface COORDINATOR;

  // Your subscription ID.
  uint64 s_subscriptionId;

  // Rinkeby coordinator. For other networks,
  // see <https://docs.chain.link/docs/vrf-contracts/#configurations>
  address vrfCoordinator = 0x6168499c0cFfCaCD319c818142124B7A15E857ab;

  // The gas lane to use, which specifies the maximum gas price to bump to.
  // For a list of available gas lanes on each network,
  // see <https://docs.chain.link/docs/vrf-contracts/#configurations>
  bytes32 keyHash = 0xd89b2bf150e3b9e13446986e571fb9cab24b13cea0a43ea20a6049a85cc807cc;

  // Depends on the number of requested values that you want sent to the
  // fulfillRandomWords() function. Storing each word costs about 20,000 gas,
  // so 100,000 is a safe default for this example contract. Test and adjust
  // this limit based on the network that you select, the size of the request,
  // and the processing of the callback request in the fulfillRandomWords()
  // function.
  uint32 callbackGasLimit = 100000;

  // The default is 3, but you can set this higher.
  uint16 requestConfirmations = 3;

  // For this example, retrieve 2 random values in one request.
  // Cannot exceed VRFCoordinatorV2.MAX_NUM_WORDS.
  uint32 numWords =  2;

  uint256[] public s_randomWords;
  uint256 public s_requestId;
  address s_owner;

  constructor(uint64 subscriptionId) VRFConsumerBaseV2(vrfCoordinator) {
    COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
    s_owner = msg.sender;
    s_subscriptionId = subscriptionId;
  }

  // Assumes the subscription is funded sufficiently.
  function requestRandomWords() external onlyOwner {
    // Will revert if subscription is not set and funded.
    s_requestId = COORDINATOR.requestRandomWords(
      keyHash,
      s_subscriptionId,
      requestConfirmations,
      callbackGasLimit,
      numWords
    );
  }
  
  function fulfillRandomWords(
    uint256, /* requestId */
    uint256[] memory randomWords
  ) internal override {
    s_randomWords = randomWords;
  }

  modifier onlyOwner() {
    require(msg.sender == s_owner);
    _;
  }
}

里面包含的配置参数:

uint64 s_subscriptionId 之前在Chainlink上注册Subscription账户获得的Subscription ID,这个参数指明了consumer合约使用哪个Subscription账户来支付请求随机数的费用;

address vrfCoordinator Chainlink VRF协调器合约(coordinator contract)在Rinkeby网络中的地址,其他网络的地址可以在合约地址页面查询。所有的随机数请求都会发到协调器合约(由协调器合约来进行费用消耗、请求的随机数个数等控制),然后该合约会创建最终的种子值并将其发送到与此VRF协调器对应的Chainlink预言机;

bytes32 keyHash gas lane的键值(key)哈希值,即你愿意为请求支付的最好gas价格(以wei为单位)。在合约地址页面,我们可以看到Rinkeby只提供了一个30 gwei Key Hash,即示例代码中keyHash的值:

主网的话会有多个选择:

uint32 callbackGasLimit 合约fulfillRandomWords()的callback请求可以使用的最大的gas值;

uint16 requestConfirmations Chainlink node等待多少个确认之后再返回随机数,等待的确认书越多,随机数的安全性越高,最少不能少于Minimum Confirmations(Rinkeby是3),最多不能超过Maximum Confirmations(Rinkeby是200);

uint32 numWords 请求返回多少个随机数,最多不超过Maximum Random Values(Rinkeby是500);

合约中还包含两个函数:

function requestRandomWords() 带着设置好的参数向VRF coordinator合约请求随机数;

function fulfillRandomWords() 接收随机数并存储在consumer合约中;