WTF Solidity极简入门: 39. 链上随机数

我最近在重新学solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新1-3讲。

推特@WTFAcademy_@0xAA_Science

WTF Academy社群: 官网 wtf.academy | discord | 微信群申请

所有代码和教程开源在github: github.com/AmazingAng/WTFSolidity


很多以太坊上的应用都需要用到随机数,例如NFT随机抽取tokenId、抽盲盒、gamefi战斗中随机分胜负等等。但是由于以太坊上所有数据都是公开透明(public)且确定性(deterministic)的,它没法像其他编程语言一样给开发者提供生成随机数的方法。这一讲我们将介绍链上(哈希函数)和链下(chainlink预言机)随机数生成的两种方法,并利用它们做一款tokenId随机铸造的NFT

链上随机数生成

我们可以一些链上的全局变量作为种子,利用keccak256()哈希函数来获取伪随机数。这是因为哈希函数具有灵敏性和均一性,可以得到“看似”随机的结果。下面的getRandomOnchain()函数利用全局变量block.numbermsg.senderblockhash(block.timestamp-1)作为种子来获取随机数:

    /** 
    * 链上伪随机数生成
    * 利用keccak256()打包一些链上的全局变量/自定义变量
    * 返回时转换成uint256类型
    */
    function getRandomOnchain() public view returns(uint256){
        // remix运行blockhash会报错
        bytes32 randomBytes = keccak256(abi.encodePacked(block.number, msg.sender, blockhash(block.timestamp-1)));
        
        return uint256(randomBytes);
    }

注意,这个方法并不安全:

  • 首先,block.numbermsg.senderblockhash(block.timestamp-1)这些变量都是公开的,使用者可以预测出用这些种子生成出的随机数,并挑出他们想要的随机数执行合约。

  • 其次,矿工可以操纵blockhashblock.timestamp,使得生成的随机数符合他的利益。

尽管如此,由于这种方法是最便捷的链上随机数生成方法,大量项目方依靠它来生成不安全的随机数,包括知名的项目meebitsloots等。当然,这些项目也无一例外的被攻击了:攻击者可以铸造任何他们想要的稀有NFT,而非随机抽取。

链下随机数生成

我们可以在链下生成随机数,然后通过预言机把随机数上传到链上。Chainlink提供VRF(可验证随机函数)服务,链上开发者可以支付LINK代币来获取随机数。 Chainlink VRF有两个版本,因为第二个版本需要官网注册并预付费,且用法类似,这里只介绍第一个版本VRF v1

Chainlink VRF
Chainlink VRF

我们将用一个简单的合约介绍如何使用Chainlink VRFRandomNumberConsumer合约可以向VRF请求一个随机数,并存储在状态变量randomResult中。

1. 用户合约继承VRFConsumerBase并转入LINK代币

为了使用VRF获取随机数,合约需要继承VRFConsumerBase合约,并在构造函数中初始化VRF Coordinator地址,LINK代币地址,唯一标识符Key Hash,和使用费用fee

注意: 不同链对应不同的参数,在这里查询。

教程中我们使用Rinkeby测试网。部署好合约后,用户需要向合约转一些LINK代币,测试网的LINK代币可以从LINK水龙头领取。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract RandomNumberConsumer is VRFConsumerBase {
    
    bytes32 internal keyHash; // VRF唯一标识符
    uint256 internal fee; // VRF使用手续费
    
    uint256 public randomResult; // 存储随机数
    
    /**
     * 使用chainlink VRF,构造函数需要继承 VRFConsumerBase 
     * 不同链参数填的不一样
     * 网络: Rinkeby测试网
     * Chainlink VRF Coordinator 地址: 0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B
     * LINK 代币地址: 0x01BE23585060835E02B77ef475b0Cc51aA1e0709
     * Key Hash: 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311
     */
    constructor() 
        VRFConsumerBase(
            0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B, // VRF Coordinator
            0x01BE23585060835E02B77ef475b0Cc51aA1e0709  // LINK Token
        )
    {
        keyHash = 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311;
        fee = 0.1 * 10 ** 18; // 0.1 LINK (VRF使用费,Rinkkeby测试网)
    }

2. 用户合约申请随机数

用户可以调用从VRFConsumerBase合约继承来的requestRandomness()申请随机数,并返回申请标识符requestId。这个申请会传递给VRF合约。

    /** 
     * 向VRF合约申请随机数 
     */
    function getRandomNumber() public returns (bytes32 requestId) {
        // 合约中需要有足够的LINK
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK - fill contract with faucet");

        return requestRandomness(keyHash, fee);
    }

3. Chainlink节点链下生成随机数和数字签名,并发送给VRF合约

4. VRF合约验证签名有效性

5. 用户合约接收并使用随机数

VRF合约验证签名有效之后,会自动调用用户合约的回退函数fulfillRandomness(),将链下生成的随机数发送过来。用户要把消耗随机数的逻辑写在这里。

注意: 用户申请随机数时调用的requestRandomness()VRF合约返回随机数时调用的回退函数fulfillRandomness()是两笔交易,调用者分别是用户合约和VRF合约,后者比前者晚几分钟(不同链延迟不一样)。

    /**
     * VRF合约的回调函数,验证随机数有效之后会自动被调用
     * 消耗随机数的逻辑写在这里
     */
    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        randomResult = randomness;
    }

tokenId随机铸造的NFT

这一节,我们将利用链上和链下随机数来做一款tokenId随机铸造的NFTRandom合约继承ERC721VRFConsumerBase合约。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "https://github.com/AmazingAng/WTFSolidity/blob/main/34_ERC721/ERC721.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract Random is ERC721, VRFConsumerBase{

状态变量

  • NFT相关

    • totalSupplyNFT总供给。

    • ids:数组,用于计算可供minttokenId,见pickRandomUniqueId()函数。

    • mintCount:已经mint的数量。

  • Chainlink VRF相关

    • keyHash:VRF唯一标识符。

    • feeVRF手续费。

    • requestToSender:记录申请VRF用于铸造的用户地址。

    // NFT相关
    uint256 public totalSupply = 100; // 总供给
    uint256[100] public ids; // 用于计算可供mint的tokenId
    uint256 public mintCount; // 已mint数量
    // chainlink VRF相关
    bytes32 internal keyHash;
    uint256 internal fee;
    // 记录VRF申请标识对应的mint地址
    mapping(bytes32 => address) public requestToSender; 

构造函数

初始化继承的VRFConsumerBaseERC721合约的相关变量。

    /**
     * 使用chainlink VRF,构造函数需要继承 VRFConsumerBase 
     * 不同链参数填的不一样
     * 网络: Rinkeby测试网
     * Chainlink VRF Coordinator 地址: 0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B
     * LINK 代币地址: 0x01BE23585060835E02B77ef475b0Cc51aA1e0709
     * Key Hash: 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311
     */
    constructor() 
        VRFConsumerBase(
            0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B, // VRF Coordinator
            0x01BE23585060835E02B77ef475b0Cc51aA1e0709  // LINK Token
        )
        ERC721("WTF Random", "WTF")
    {
        keyHash = 0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311;
        fee = 0.1 * 10 ** 18; // 0.1 LINK (VRF使用费,Rinkkeby测试网)
    }

其他函数

除了构造函数以外,合约里还定义了5个函数。

  • pickRandomUniqueId():输入随机数,获取可供minttokenId

  • getRandomOnchain():获取链上随机数(不安全)。

  • mintRandomOnchain():利用链上随机数铸造NFT,调用了getRandomOnchain()pickRandomUniqueId()

  • mintRandomVRF():申请Chainlink VRF用于铸造随机数。由于使用随机数铸造的逻辑在回调函数fulfillRandomness(),而回调函数的调用者是VRF合约,而非铸造NFT的用户,这里必须利用requestToSender状态变量记录VRF申请标识符对应的用户地址。

  • fulfillRandomness()VRF的回调函数,由VRF合约在验证随机数真实性后自动调用,用返回的链下随机数铸造NFT

    /** 
    * 输入uint256数字,返回一个可以mint的tokenId
    */
    function pickRandomUniqueId(uint256 random) private returns (uint256 tokenId) {
        uint256 len = totalSupply - mintCount++; // 可mint数量
        require(len > 0, "mint close"); // 所有tokenId被mint完了
        uint256 randomIndex = random % len; // 获取链上随机数

        tokenId = ids[randomIndex] != 0 ? ids[randomIndex] : randomIndex; // 获取tokenId
        ids[randomIndex] = ids[len - 1] == 0 ? len - 1 : ids[len - 1]; // 更新ids 列表
        ids[len - 1] = 0; // 删除最后一个元素
    }

    /** 
    * 链上伪随机数生成
    * keccak256(abi.encodePacked()中填上一些链上的全局变量/自定义变量
    * 返回时转换成uint256类型
    */
    function getRandomOnchain() public view returns(uint256){
        // remix跑blockhash会报错
        bytes32 randomBytes = keccak256(abi.encodePacked(block.number, msg.sender, blockhash(block.timestamp-1)));
        return uint256(randomBytes);
    }

    // 利用链上伪随机数铸造NFT
    function mintRandomOnchain() public {
        uint256 _tokenId = pickRandomUniqueId(getRandomOnchain()); // 利用链上随机数生成tokenId
        _mint(msg.sender, _tokenId);
    }

    /** 
     * 调用VRF获取随机数,并mintNFT
     * 要调用requestRandomness()函数获取,消耗随机数的逻辑写在VRF的回调函数fulfillRandomness()中
     * 调用前,把LINK代币转到本合约里
     */
    function mintRandomVRF() public returns (bytes32 requestId) {
        // 检查合约中LINK余额
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK - fill contract with faucet");
        // 调用requestRandomness获取随机数
        requestId = requestRandomness(keyHash, fee);
        requestToSender[requestId] = msg.sender;
        return requestId;
    }

    /**
     * VRF的回调函数,由VRF Coordinator调用
     * 消耗随机数的逻辑写在本函数中
     */
    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        address sender = requestToSender[requestId]; // 从requestToSender中获取minter用户地址
        uint256 _tokenId = pickRandomUniqueId(randomness); // 利用VRF返回的随机数生成tokenId

        _mint(sender, _tokenId);
    }

总结

Solidity中生成随机数没有其他编程语言那么容易。这一讲我们将介绍链上(哈希函数)和链下(chainlink预言机)随机数生成的两种方法,并利用它们做一款tokenId随机铸造的NFT。这两种方法各有利弊:使用链上随机数高效,但是不安全;而链下随机数生成依赖于第三方提供的预言机服务,比较安全,但是没那么简单经济。项目方要根据业务场景来选择适合自己的方案。

Subscribe to 0xAA
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.