# WTF Solidity极简入门: 39. 链上随机数 **Published by:** [0xAA](https://paragraph.com/@wtfacademy/) **Published on:** 2022-07-19 **URL:** https://paragraph.com/@wtfacademy/wtf-solidity-39 ## Content 我最近在重新学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.number,msg.sender和blockhash(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.number,msg.sender和blockhash(block.timestamp-1)这些变量都是公开的,使用者可以预测出用这些种子生成出的随机数,并挑出他们想要的随机数执行合约。其次,矿工可以操纵blockhash和block.timestamp,使得生成的随机数符合他的利益。尽管如此,由于这种方法是最便捷的链上随机数生成方法,大量项目方依靠它来生成不安全的随机数,包括知名的项目meebits,loots等。当然,这些项目也无一例外的被攻击了:攻击者可以铸造任何他们想要的稀有NFT,而非随机抽取。链下随机数生成我们可以在链下生成随机数,然后通过预言机把随机数上传到链上。Chainlink提供VRF(可验证随机函数)服务,链上开发者可以支付LINK代币来获取随机数。 Chainlink VRF有两个版本,因为第二个版本需要官网注册并预付费,且用法类似,这里只介绍第一个版本VRF v1。使用Chainlink VRFChainlink VRF我们将用一个简单的合约介绍如何使用Chainlink VRF。RandomNumberConsumer合约可以向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随机铸造的NFT。Random合约继承ERC721和VRFConsumerBase合约。// 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相关totalSupply:NFT总供给。ids:数组,用于计算可供mint的tokenId,见pickRandomUniqueId()函数。mintCount:已经mint的数量。Chainlink VRF相关keyHash:VRF唯一标识符。fee:VRF手续费。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; 构造函数初始化继承的VRFConsumerBase和ERC721合约的相关变量。 /** * 使用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():输入随机数,获取可供mint的tokenId。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。这两种方法各有利弊:使用链上随机数高效,但是不安全;而链下随机数生成依赖于第三方提供的预言机服务,比较安全,但是没那么简单经济。项目方要根据业务场景来选择适合自己的方案。 ## Publication Information - [0xAA](https://paragraph.com/@wtfacademy/): Publication homepage - [All Posts](https://paragraph.com/@wtfacademy/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@wtfacademy): Subscribe to updates - [Twitter](https://twitter.com/0xAA_Science): Follow on Twitter