Solidity语言 - 如何在智能合约中使用Chainlink VRF - Quicknode
本文详细介绍了如何在智能合约中使用Chainlink VRF生成随机数。文章涵盖了Chainlink VRF的原理、请求随机数的方法,并通过构建一个自定义的抽奖智能合约进行了系统的示范。还提供了关于测试、部署合约和创建Chainlink VRF订阅的步骤,有助于开发者理解如何在其项目中实现该功能。
重要通知
本指南包含对 Goerli 测试网的引用,而该网络已不再积极维护。尽管与该链相关的特定步骤可能不适用,但总体过程可能对其他链仍然有效。我们建议你探索当前可用的替代方案。如果你想查看本指南的更新版本,请 告知我们!
概述
在区块链上生成随机性可能比较困难,因为智能合约是确定性的,并且可能被预测。然而, Chainlink 解决了这个问题。本文将教你更多关于 Chainlink VRF 的知识以及它是如何工作的。此外,你还将了解两种不同的 Chainlink VRF 方法——直接资金方法和订阅方法。稍后在文章中,你将使用 Remix.IDE 部署一个自定义彩票智能合约,并使用 Chainlink VRF 生成一个随机数。
你需要准备的内容
-
基本理解 Solidity
-
MetaMask 钱包
-
Goerli 测试网的 ETH (你可以在 QuickNode 饮水机 获得一些)
-
测试网 LINK 代币
你将要做的事情
- 了解 Chainlink VRF 和不同的请求方法
- 创建并部署一个使用 Chainlink VRF 的自定义彩票智能合约
- 在 Goerli 测试网上使用 Remix.IDE 测试彩票智能合约
什么是 Chainlink VRF?
Chainlink VRF 是一个公平且可验证的随机数生成器 (RNG),允许智能合约在不危及安全的情况下访问随机值。对于每个请求,Chainlink VRF 将返回一个或多个随机值以及链上的加密证明。
你可以使用 Chainlink VRF 为你的智能合约构建可证明的公平和可验证的随机数。潜在的用例包括:
- 区块链游戏/元宇宙
- PVP 匹配:创建可证明公平的匹配系统,让用户知道不会发生操控。
- 不可预测的游戏:为游戏添加可证明的公平随机性(例如,游戏中的随机奖品)
- NFT
- 随机选出社区或在线活动的获胜者
- DeFi
- 在投入的池中选出获胜者
- 消费者广告与奖励
- 随机选出在线活动的获胜者
- 随机选出忠诚度会员俱乐部的获胜者
- 公平概率(例如,在线博彩)
- 在在线博彩游戏、战利品盒等中随机化概率。
- 安全性
- 在身份验证流程中添加无法预测的随机性
这些只是一些例子。利用你的创造力和经验来尝试新的想法吧!
Chainlink VRF 如何工作?
正如我们所讨论的,Chainlink VRF 帮助你获取和返回来自智能合约的随机数据。但是,在你可以使用它之前,你需要使用 LINK 代币为你的账户提供资金,LINK 代币是 Chainlink 的原生和实用代币。如果你想在主网部署合约,你需要从交易所获取 LINK 代币。
然而,为了开发目的,你可以在 Goerli 或 Mumbai 测试网络上使用测试网 LINK 代币(你可以在这个 饮水机 上获得一些)。下一步将是导入 Chainlink VRF 实现,然后在你的合约中相应使用它们。
一旦 Chainlink VRF 被纳入你的智能合约,每当请求随机性时,Chainlink 将在后台执行以下操作:
一个 Chainlink 节点将使用当前区块中的一些数据来编程随机性。
生成随机性后,它将向 VRF 合约发送加密证明。这在随机性公开显示在区块链上之前必须获得批准。
所有这些链上和链下的通信都在相对较短的时间范围内发生。
请求随机性的两种方法
Chainlink VRF 提供两种请求随机性的方法:
- 订阅模型
要使用此方法,你必须创建一个订阅账户,并使用 LINK 代币为该账户提供资金。用户可以在此后连接多个合约,而无需分别为每个合约提供资金。
该方法允许你通过一个订阅为多个消费合约提供资金。当消费合约请求随机值时,请求将被满足,交易费用将被计算。然后余额将相应扣除。
- 直接资金
在此方法中,一旦用户请求随机值,消费合约将使用 LINK 代币支付。因此,你必须直接为你的消费合约提供资金,并确保有足够的 LINK 代币用于随机请求的支付。此外,你还可以通过在智能合约中添加额外逻辑,将 LINK 成本传递给用户。
如何在订阅模型与直接资金模型之间选择合适的方法
每种模型都有其优点,没有一种比另一种更好。这完全取决于你合约和 DApp 的具体用例。关于这一点,你可以考虑以下事实,以做出更好的选择:
- 如果你的 DApp 需要定期随机请求,订阅模型会更好且更具成本效益。另一方面,处理一次性请求时,直接资助的方法是更好的选择。
- 如果你有多个 VRF 消费合约,订阅模型更好。
- 如果你关注优化请求的 Gas 价格,订阅方法更可取。
- 不像直接资金方法,订阅可以在单个请求中返回多个随机词。
使用 Chainlink VRF 创建自定义彩票合约
到目前为止,我们已经建立并维护了对 Chainlink VRF 工作原理的足够概念理解。因此,我们将继续构建一个彩票合约,尝试从开发视角探讨我们讨论的所有内容。
在 Remix.IDE 中打开一个新的空白工作区,然后创建一个名为 game.sol 的 Solidity 文件。
步骤 1:导入依赖项
由于我们将与 Chainlink 接口进行交互,因此我们必须在合约中导入并继承三个依赖项:VRFCoordinatorV2Interface、VRFConsumerBaseV2 和 ConfirmedOwner 合约。
VRFCoordinatorV2Interface 是一个可以请求随机性并管理订阅的功能依赖项。VRFConsumerBaseV2 是与消费者合约进行有效通信的工具。然后 ConfirmedOwner 依赖于安全性,它印证了所有者并设置远离操控者。
打开 game.sol 文件并将以下代码添加到顶部:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";
步骤 2:设定状态变量
接下来,我们将创建一个名为 Game 的合约,并声明智能合约逻辑(即事件、结构体、映射和状态变量)。在 game.sol 中的导入语句下添加以下代码:
contract Game is VRFConsumerBaseV2, ConfirmedOwner {
event RequestSent(uint256 requestId, uint32 numWords);
event RequestFulfilled(uint256 requestId, uint256[] randomWords);
struct RequestStatus {
bool fulfilled; // 请求是否已成功完成
bool exists; // requestId 是否存在
uint256[] randomWords;
}
mapping(uint256 => RequestStatus)
public s_requests; /* requestId --> requestStatus */
VRFCoordinatorV2Interface COORDINATOR;
// 你的订阅 ID。
uint64 s_subscriptionId;
// 过去的请求 ID。
uint256[] public requestIds;
uint256 public lastRequestId;
bytes32 immutable keyHash;
address public immutable linkToken;
uint32 callbackGasLimit = 150000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
uint public randomWordsNum;
前两个初始事件(即 RequestSent 和 RequestFulfilled)在用户请求随机值时记录;一个记录发送的值,另一个记录满足的值。
然后有一个结构体(即 RequestStatus),用于记录关于请求的状态的数据,无论它是否已被满足或根本存在。创建这个结构体后,我们需要跟踪它,因此需要映射。
随后,我们将 keyHash 和 linkToken 引入合约。其他部分设置 gaslimit、我们期望的随机词数量和请求确认。
此外,我们还需要为我们的彩票合约创建状态变量。
在你之前粘贴的代码下添加以下代码到 game.sol。
address[] public players;
uint maxPlayers;
bool public gameStarted;
uint public entryfee;
uint public gameId;
address public recentWinner;
event GameStarted(uint gameId, uint maxPlayers, uint entryfee);
event PlayerJoined(uint gameId, address player);
event GameEnded(uint gameId, address winner);
首先,我们创建了一个 players 数组并将其可见性设置为 public。然后,我们创建了一个无符号整数以表示最大玩家数 maxPlayers。彩票必须在每个人可以参与之前启动,因此我们创建了一个布尔数据类型 gameStarted,以跟踪游戏状态。
每个参与者将支付一个 entryfee,每个游戏将拥有一个唯一的 gameId。然后我们创建了一个地址数据类型,用于存储 recentWinner。
这些事件跟踪游戏何时开始、结束,以及每当有玩家加入时。你可以使用 eth_getLogs 方法查找这些事件的日志。
步骤 3:在构造函数中初始化
我们最初导入了一些依赖项,这将在构造函数中初始化。首先,我们设置一些构造函数参数(例如,subscriptionId、_\linkToken),这些参数指的是我们的合约用于资金支持的订阅 ID 和我们部署网络的 LINK 代币地址。
我们将 VRFConsumerBaseV2 初始化并调用 ConfirmedOwner 函数以设置 msg.sender 为所有者。设置好我们的 COORDINATOR 变量与 VRFCoordinatorV2Interface 后,设置 s_subscriptionId。请注意,要注意你正在部署的网络并根据正确的值获取相应的内容。最后,我们设置 keyHash、你愿意为请求支付的最大 Gas 价格(以 Wei 为单位)、linkToken 地址,以及我们的 gameStarted 变量设置为 false。
在本教程中,我们将使用 Goerli 测试网。更重要的是,我们将游戏的开始状态设置为 false,直到所有者启动它。
在之前你粘贴的代码下添加以下代码到 game.sol。
constructor(
uint64 subscriptionId,
address _linkToken
)
VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D)
ConfirmedOwner(msg.sender)
{
COORDINATOR = VRFCoordinatorV2Interface(
0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
);
s_subscriptionId = subscriptionId;
keyHash = 0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15; // 我们已经设置过这个
linkToken = _linkToken;
gameStarted = false;
}
步骤 4:startGame 函数
在你之前粘贴的代码下添加以下代码到 game.sol。
function startGame(uint _maxPlayers, uint _entryfee) public {
require(!gameStarted, "游戏已经开始了");
players = new address[](0);
maxPlayers = _maxPlayers;
gameStarted = true;
entryfee = _entryfee; // 以 wei 为单位的入场费(18 个零)
gameId += 1;
emit GameStarted(gameId, maxPlayers, entryfee);
}
我们以上创建了一个 startGame 函数并将其设为 public。然后,我们重置 players 数组和最大玩家数。
当游戏开始时,我们将 gameID 增加 1,并将 gameStarted 更新为 true。
步骤 5:joinGame 函数
在你之前粘贴的代码下添加以下代码到 game.sol。
function joinGame() public payable {
require(gameStarted, "游戏尚未启动");
require(players.length < maxPlayers, "游戏已经满员!");
require(
msg.value == entryfee,
"金额应等于入场费"
);
players.push(msg.sender);
emit PlayerJoined(gameId, msg.sender);
if (players.length == maxPlayers) {
getRandomWinner();
}
}
在上述的 joinGame 函数中,我们必须设置三个检查条件。大致参与者不能加入:
- 游戏尚未开始
- 玩家人数已满
- 参与者未支付入场费
一旦玩家通过验证,参与者将被添加到玩家数组中。我们然后检查玩家数是否小于设置的 maxPlayers。一旦最后一名玩家加入游戏 (players.length == maxPlayers), getRandomWinner 函数将被调用以选出随机获胜者。
步骤 6:pickWinner 函数
在你之前粘贴的代码下添加以下代码到 game.sol。
function getRandomWinner() internal returns (address) {
uint256 requestId = requestRandomWords();
uint256 winnerIndex = randomWordsNum % players.length;
recentWinner = players[winnerIndex];
(bool success, ) = recentWinner.call{value: address(this).balance}("");
require(success, "无法发送以太");
gameStarted = false;
emit GameEnded(gameId, recentWinner);
return recentWinner;
}
在 getRandomWinner 函数中,我们将 requestId 指向 requestRandomWords。然后我们使用玩家数组和 randomWordsNum 进行取模运算以生成随机性。
其成功后,我们调用转账给 recentWinner 并返回其地址以供公开可见。
步骤 7:设置获取随机性的逻辑
你必须实现这三个主要的 Chainlink 函数以生成随机数:requestRandomWords 函数、fulfillRandomWords 函数和 getRequestStatus 函数。让我们花些时间了解这些函数如何运作以生成随机性。
当我们调用 requestRandomWords 函数时,Chainlink oracle 会发起一个请求以生成随机数。
该请求包含一个 requestId 变量,将数据传递给 VRF 协调器,例如我们的订阅 id、请求确认限额、回调 Gas 限额以及我们想要的随机数数量。完成后,oracle 将返回一个 ID(例如,requestId)。合约还将在一个映射(例如,s_requests)中存储返回值。
那么返回的 requestId 有什么用途?它在回调函数 fulfillRandomWords 中使用,其中它接受一个 requestId(在请求随机词功能调用完成时)和一个随机词数组(即数字)。请注意,在此回调函数中,你可以实施逻辑以修改返回的随机数或更新状态变量等。
function requestRandomWords() public onlyOwner returns (uint256 requestId) {
requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
s_requests[requestId] = RequestStatus({
randomWords: new uint256[](0),
exists: true,
fulfilled: false
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, numWords);
return requestId; // requestID 是 uint。
}
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
require(s_requests[_requestId].exists, "请求未找到");
s_requests[_requestId].fulfilled = true;
s_requests[_requestId].randomWords = _randomWords;
randomWordsNum = _randomWords[0]; // 将数组索引设置为变量,更方便处理
emit RequestFulfilled(_requestId, _randomWords);
}
// 检查随机数请求状态的函数
function getRequestStatus(
uint256 _requestId
) external view returns (bool fulfilled, uint256[] memory randomWords) {
require(s_requests[_requestId].exists, "请求未找到");
RequestStatus memory request = s_requests[_requestId];
return (request.fulfilled, request.randomWords);
}
请注意,你可以通过更新 requestRandomWords 函数中的 numWords 变量从一个 VRF 请求中一次请求多个随机值。此外,根据当前的测试网条件,可能需要几分钟的时间获取请求的随机值回到你的合约。请访问 vrf.chain.link 查看你的订阅的未处理请求列表。
最后,game.sol 中的完整代码应如下所示:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";
contract Game is VRFConsumerBaseV2, ConfirmedOwner {
event RequestSent(uint256 requestId, uint32 numWords);
event RequestFulfilled(uint256 requestId, uint256[] randomWords);
struct RequestStatus {
bool fulfilled; // 请求是否已成功完成
bool exists; // requestId 是否存在
uint256[] randomWords;
}
mapping(uint256 => RequestStatus)
public s_requests; /* requestId --> requestStatus */
VRFCoordinatorV2Interface COORDINATOR;
// 你的订阅 ID。
uint64 s_subscriptionId;
// 过去的请求 ID。
uint256[] public requestIds;
uint256 public lastRequestId;
bytes32 immutable keyHash;
address public immutable linkToken;
uint32 callbackGasLimit = 150000;
uint16 requestConfirmations = 3;
uint32 numWords = 1;
uint public randomWordsNum;
address[] public players;
uint maxPlayers;
bool public gameStarted;
uint public entryfee;
uint public gameId;
address public recentWinner;
event GameStarted(uint gameId, uint maxPlayers, uint entryfee);
event PlayerJoined(uint gameId, address player);
event GameEnded(uint gameId, address winner);
constructor(
uint64 subscriptionId,
address _linkToken
)
VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D)
ConfirmedOwner(msg.sender)
{
COORDINATOR = VRFCoordinatorV2Interface(
0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D
);
s_subscriptionId = subscriptionId;
keyHash = 0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15; // 我们已经设置过这个
linkToken = _linkToken;
gameStarted = false;
}
receive() external payable {}
function startGame(uint _maxPlayers, uint _entryfee) public {
require(!gameStarted, "游戏已经开始了");
players = new address[](0);
maxPlayers = _maxPlayers;
gameStarted = true;
entryfee = _entryfee; // 以 wei 为单位的入场费(18 个零)
gameId += 1;
emit GameStarted(gameId, maxPlayers, entryfee);
}
function joinGame() public payable {
require(gameStarted, "游戏尚未启动");
require(players.length < maxPlayers, "游戏已经满员!");
require(
msg.value == entryfee,
"金额应等于入场费"
);
players.push(msg.sender);
emit PlayerJoined(gameId, msg.sender);
if (players.length == maxPlayers) {
getRandomWinner();
}
}
function getRandomWinner() internal returns (address) {
uint256 requestId = requestRandomWords();
uint256 winnerIndex = randomWordsNum % players.length;
recentWinner = players[winnerIndex];
(bool success, ) = recentWinner.call{value: address(this).balance}("");
require(success, "无法发送以太");
gameStarted = false;
emit GameEnded(gameId, recentWinner);
return recentWinner;
}
function requestRandomWords() public onlyOwner returns (uint256 requestId) {
requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
s_requests[requestId] = RequestStatus({
randomWords: new uint256[](0),
exists: true,
fulfilled: false
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, numWords);
return requestId; // requestID 是 uint。
}
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
require(s_requests[_requestId].exists, "请求未找到");
s_requests[_requestId].fulfilled = true;
s_requests[_requestId].randomWords = _randomWords;
randomWordsNum = _randomWords[0]; // 将数组索引设置为变量,更方便处理
emit RequestFulfilled(_requestId, _randomWords);
}
// 检查随机数请求状态的函数
function getRequestStatus(
uint256 _requestId
) external view returns (bool fulfilled, uint256[] memory randomWords) {
require(s_requests[_requestId].exists, "请求未找到");
RequestStatus memory request = s_requests[_requestId];
return (request.fulfilled, request.randomWords);
}
}
设置 Chainlink VRF 订阅
现在游戏合约已经创建,我们可以进行创建 Chainlink 订阅的下一步。然而,在我们这样做之前,请确保你在 Goerli 测试网上有一些 ETH。你可以在 QuickNode 饮水机 获取一些。
然后,访问 Chainlink 订阅 页面以创建一个订阅。连接你的 Metamask 钱包,然后单击 创建订阅 按钮。最后一步是在你的 MetaMask 钱包中签署交易(确保你在 Goerli 测试网上)。

下一阶段是为你的 Chainlink 订阅提供资金。点击 添加资金 并添加 10 个 LINK 代币。完成后,你的仪表盘应如下所示:

创建参与合约
我们需要编写并部署一个合约,作为彩票参与者。该合约的主要逻辑是存入资金并调用 joinGame 函数。
创建一个名为 GameParticipant.sol 的文件,然后将以下代码添加到文件中:
pragma solidity 0.8.0;
contract joinGameContract {
constructor() payable {}
receive() external payable {}
function joinGame(address _addr) public payable {
(bool success, ) = _addr.call{value: address(this).balance}(
abi.encodeWithSignature("joinGame()")
);
require(success, "交易失败");
}
}
该合约调用 joinGame 函数,进行转账调用,并包含一个 receive 回退函数以接收以太。
测试彩票合约
此时,我们将开始通过部署彩票合约(例如,game.sol)来进行测试,但在此之前,我们必须先编译智能合约。在 Solidity 编译器选项卡中,选择 game.sol 文件并单击编译按钮。然后对 GameParticipant.sol 重复此操作。
请注意,如果启用了自动编译选项,你无需再次编译合约。
然后,一旦你的合约已经编译并准备部署,导航到 Remix.IDE 的 DEPLOY & RUN TRANSACTIONS 选项,并在环境下拉菜单中选择 Injected Provider 选项。然后,在合约下拉菜单中选择 Game 合约(例如,game.sol)。
你必须将你在 Chainlink 订阅仪表盘上找到的订阅 ID 输入构造函数参数中,还要输入 LINK 代币地址:0x326C977E6efc84E512bB9C30f76E30c160eD06FB

部署成功后,你必须将合约地址添加为消费者,并在你的 MetaMask 钱包中签署交易。

下一步是与 startGame 函数进行交互。将玩家数量设置为 2,将入场费设置为 1 wei。你需要在你的 MetaMask 钱包中签署此交易。

接下来,我们需要部署 joinGameContract 合约(即 GameParticipant.sol)。在 Remix.IDE 中选择该合约,然后转到 DEPLOY & RUN TRANSACTIONS 选项卡以部署合约。

在你签署交易并部署合约后,在 Remix.IDE 中的值字段中输入 1 wei,然后调用 joinGame 函数。你需要将 game.sol 合约的地址作为参数传递,并在 MetaMask 中签署该交易。

一旦 GameParticipant 合约加入彩票,返回 game.sol 合约并调用 joinGame 函数,将 1 wei 传入你的交易中。

一旦最大玩家数已加入,合约将向 Chainlink VRF 发送请求以检索随机数并返回获胜者的地址。要查看获胜者,请单击 recentWinner 变量以读取其状态。

结论
恭喜你!你刚刚学习了 Chainlink VRF 以及如何从智能合约请求随机数。Chainlink 代表了区块链技术的重大进步,因为它为开发人员提供了可证明的公平随机数生成器。
Chainlink VRF 的用例不仅限于在本文章中我们所做的彩票随机获胜者;你可以利用它在你未来可能构建的任何合约中生成随机性。
我们希望看到你创造的内容!请在 Discord 或 Twitter 与我们分享你的想法。如果你对本指南有任何反馈或问题,我们希望听到你的声音!
- 原文链接: quicknode.com/guides/eth...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~