Solidity极简入门: 39. 链上随机数
我最近在重新学solidity,巩固一下细节,也写一个“Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新1-3讲。
discord:WTF Academy
所有代码和教程开源在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 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使用费,Rinkeby测试网)
}
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使用费,Rinkeby测试网)
}
其他函数
除了构造函数以外,合约里还定义了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,作为数组下标,同时记录value为len-1,如果取模得到的值已存在,则tokenId取该数组下标的value
tokenId = ids[randomIndex] != 0 ? ids[randomIndex] : randomIndex; // 获取tokenId
ids[randomIndex] = ids[len - 1] == 0 ? len - 1 : ids[len - 1]; // 更新ids 列表
ids[len - 1] = 0; // 删除最后一个元素,能返还gas
}
/**
* 链上伪随机数生成
* 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);
}
remix
验证
1. 在Rinkeby
测试网部署Random
合约
2. 利用Chainlink
水龙头获取测试网的LINK
和ETH
3. 将LINK
代币转入Random
合约
合约部署后,拷贝合约地址,像普通转账一样,通过小狐狸钱包转账LINK
到合约地址
4. 利用链上随机数铸造NFT
在remix
界面中,点击左侧橙色函数mintRandomOnchain
,在弹出的小狐狸钱包中点击确认,利用链上随机数铸造交易就开始了
5. 利用Chainlink VRF
链下随机数铸造NFT
同理,在remix
界面中,点击左侧橙色函数mintRandomVRF
,在弹出的小狐狸钱包中点击确认,利用Chainlink VRF
链下随机数铸造交易就开始了
注意: 采用VRF
铸造NFT
时,发起交易和铸造成功不在同一个区块
6. 验证NFT
已被铸造
通过以上截图可以看出,本例中,tokenId=87
的NFT
被链上随机铸造出来,tokenId=77
的NFT
被VRF
铸造出来。
总结
在Solidity
中生成随机数没有其他编程语言那么容易。这一讲我们将介绍链上(哈希函数)和链下(chainlink
预言机)随机数生成的两种方法,并利用它们做一款tokenId
随机铸造的NFT
。这两种方法各有利弊:使用链上随机数高效,但是不安全;而链下随机数生成依赖于第三方提供的预言机服务,比较安全,但是没那么简单经济。项目方要根据业务场景来选择适合自己的方案。