Skip to content

使用随机数预编译创建一个彩票合约

作者:Erin Shaben

概览

Moonbeam使用可验证随机函数(Verifiable Random Functions,VRF)生成可以在链上验证的随机数。VRF是一种加密函数,它接受一些输入并产生随机值以及这些随机值是由提交者生成的真实性证明。此证明可以由任何人验证,以确保生成的随机数值计算正确。

目前有两种可用随机数来源,他们基于区块生产者的VRF密钥以及过去的随机数结果提供随机输入:本地VRFBABE Epoch随机数。本地VRF直接在Moonbeam中使用区块收集人的VRF密钥以及最新区块的VRF输出值决定。而BABE Epoch随机数是基于由中继链验证人在一个完整Epoch期间生产的所有VRF。

获取关于这两种随机数的更多信息,包括请求和履行流程如何工作,以及安全考量,请参考Moonbeam上的随机数页面。

Moonbeam提供随机数预编译,这是一个Solidity接口,使智能合约开发者能够使用以太坊API通过本地VRF或BABE epoch随机数生成随机数。Moonbeam也提供一个随机数消费者Solidity合约,您的合约必须继承自该合约才能使用已履行的随机数请求。

本教程将向您展示如何使用随机数预编译和随机数消费者合约创建一个随机挑选获胜者的彩票合约。

查看先决条件

在开始操作之前,您需要准备以下内容:

  • 在Moonbase Alpha上创建/拥有三个账户,用于测试彩票合约
  • 所有账户必须拥有一些DEV Token 您可以每24小时一次从Moonbase Alpha水龙头上获取DEV代币以在Moonbase Alpha上进行测试
  • 一个已配置Moonbase Alpha测试网的空白Hardhat项目。要获取分步操作教程,请参考创建一个Hardhat项目和我们Hardhat文档页面的Hardhat配置文件部分
  • 安装Hardhat Ethers插件。这将为您提供更简便的方式以使用Ethers.js库与Hardhat项目中的网络交互:

    npm install @nomicfoundation/hardhat-ethers ethers
    

注意事项

要在Moonbeam或Moonriver网络上测试本指南中的示例,您可以从受支持的网络端点提供商之一获取您自己的端点和API密钥。

合约设置

以下为我们本次操作指南中创建彩票合约会用到的合约:

  • Randomness.sol - 随机数预编译,这是一个Solidity接口,允许您请求随机数、获取关于随机数请求的信息、并履行请求等
  • RandomnessConsumer.sol - 随机数消费者,是一个抽象的Solidity合约,用于与随机数预编译交互。此合约负责验证随机数请求的origin(来源),确保随机数预编译始终是origin,并履行请求
  • Lottery.sol - 一个示例彩票合约,我们将在本教程中构建此合约。它将依靠随机数预编译和随机数消费者来请求用于挑选彩票获胜者的随机词

如果您尚未在Hardhat项目中创建contracts目录,您需要创建一个新目录:

mkdir contracts && cd contracts

然后您可以创建以下三个文件,每个文件对应上述合约:

touch Randomness.sol RandomnessConsumer.sol Lottery.sol

Randomness.sol文件中,您可以粘贴随机数预编译合约。同样的,在RandomnessConsumer.sol文件中,您可以粘贴随机数消费者合约

我们将在以下部分开始添加功能至Lottery.sol合约。

创建彩票智能合约

从更高层面来说,我们正在创建的彩票合约将定义彩票规则,允许参与并使用随机生成的词来公平挑选获胜者。我们将通过随机数预编译请求随机词。然后,我们将使用随机数消费者接口消费已完成请求的结果,以便我们的合约可以使用随机生成的词挑选获胜者并支付奖励。我们将会在构建彩票合约时演示分步流程。但是现在,您可以查看下图了解整个流程。

Diagram of the Lottery process.

此合约仅用于演示目的,不可用于生产环境。

开始之前,请先设置彩票合约。为此,您需要:

  • 导入Randomness.sol预编译和RandomnessConsumer.sol接口
  • 继承RandomnessConsumer接口
  • 创建一个随机数预编译变量randomness,以便我们后续轻松访问其函数
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity >=0.8.0;

import "./Randomness.sol";
import {RandomnessConsumer} from "./RandomnessConsumer.sol";

contract Lottery is RandomnessConsumer {
    // Randomness Precompile interface
    Randomness public randomness =
        Randomness(0x0000000000000000000000000000000000000809);
}

为Lottery和随机数请求定义参数

接下来,我们需要为彩票定义规则,例如:

  • 参与费
  • 最低/最高参与人数
  • 彩票的最短长度
  • 获胜者人数

Lottery合约中,您可以添加以下参数:

// 中奖人数。此数字对应多少个随机词将被请求。不能超过MAX_RANDOM_WORDS(来自随机数预编译)
uint8 public NUM_WINNERS = 2;

// 可以履行请求之前的区块数(针对本地VRF随机数)。MIN_VRF_BLOCKS_DELAY(来自随机数预编译)
// 为经济价值低的游戏提供足够安全的最小数量。稍微增加延迟会降低收集人能够预测伪随机数的本已很低的概率
uint32 public VRF_BLOCKS_DELAY = MIN_VRF_BLOCKS_DELAY;

// 开始抽奖的最低参与人数
uint256 public MIN_PARTICIPANTS = 3;

// 允许的最高参与人数。 重要的是限制总累积奖池(通过限制参与者数量)以保证收集人的经济激励
// 以避免试图影响伪随机数。(有关详细信息,请参阅 Randomness.sol)
uint256 public MAX_PARTICIPANTS = 20;

// 参与抽奖所需的费用。将进入累积奖池
uint256 public PARTICIPATION_FEE = 100000 gwei;

我们也需要定义一些与请求随机数特别相关的参数:

  • 履行随机数请求的交易的gas限制
  • 开始抽奖并请求随机词所需的最低费用。每个随机数请求都需要支付执行费用。此费用的目的是支付随机数请求的执行费用,这样就可以允许任何人履行请求,因为该请求的费用已支付。当提交随机数请求时,需要指定退款账户,用于接收多余的退款。设置合约时需要设置彩票合约的所有者将收到退款
  • salt前缀和全局请求计数都将用于生成唯一的随机数请求

接下来,您可以添加以下参数:

// 用于执行的gas限制,其取决于执行的代码和请求的字词个数。
// 根据请求的大小和fulfillRandomWords()函数中回调请求的处理来测试和调整这个限制
uint64 public FULFILLMENT_GAS_LIMIT = 100000;

// 开始抽奖所需的最低费用。这并不能保证有足够的费用来支付履行所使用的gas。
// 理想情况下,考虑到可能的gas价格波动,应该设一个较大的值。额外费用将退还给调用者
uint256 public MIN_FEE = FULFILLMENT_GAS_LIMIT * 150 gwei;

// 一个字符串,用于允许使用与其他合约不同的salt
bytes32 public SALT_PREFIX = "my_demo_salt_change_me";

// 存储提交的请求的全局数量。这个数字被用作使每个请求唯一的salt
uint256 public globalRequestCount;

除了这些参数,我们需要创建一些变量,用于追踪当前的彩票:

  • 当前的请求ID
  • 当前的参与者列表
  • 奖池设置
  • 彩票合约的所有者。这是必不可少的,因为只有合约所有者才有权限开启抽奖
  • 所使用的随机数来源(本地VRF或BABE epoch)
// The current request id
uint256 public requestId;

// The list of current participants
address[] public participants;

// The current amount of token at stake in the lottery
uint256 public jackpot;

// the owner of the contract
address owner;

// Which randomness source to use. This correlates to the values in the
// RandomnessSource enum in the Randomness Precompile
Randomness.RandomnessSource randomnessSource;

创建构造函数

现在,我们已经完成了彩票所需的所有变量的初始设置,接下来我们可以开始编写函数以设置彩票。首先,我们将从创建构造函数开始。

构造函数接受一个uint8参数作为随机数来源,这对应位于随机数预编译中的RandomnessSource enum中定义的随机数类型的索引。因此,我们需要为本地VRF传入0或者为BABE epoch随机数传入1。此函数将是payable,因为我们需要在部署时提交保证金并在后续用于执行随机数请求

保证金在随机数预编译中定义,这是和执行费用一样必不可少的。在完成请求后,保证金将退还给初始请求者,在本示例中为合约的所有者。如果未完成请求,则该请求会过期且需要被清除。请求清除后,保证金将被退还。

constructor(
    Randomness.RandomnessSource source
) payable RandomnessConsumer() {
    // Because this contract can only perform one randomness request at a time,
    // we only need to have one required deposit
    uint256 requiredDeposit = randomness.requiredDeposit();
    if (msg.value < requiredDeposit) {
        revert("Deposit too Low");
    }
    // Update parameters
    randomnessSource = source;
    owner = msg.sender;
    globalRequestCount = 0;
    jackpot = 0;
    // Set the requestId to the maximum allowed value by the precompile (64 bits)
    requestId = 2 ** 64 - 1;
}

为彩票中的参与者添加逻辑

接下来,我们可以创建函数,以允许用户参与到彩票抽奖中。participate函数将是payable,因为每个参与者将需要提交一个参与费。

participate函数将包含以下逻辑:

  • 使用随机数预编译的getRequestStatus函数检查彩票是否尚未开始。此函数将返回通过RequestStatus enum定义的状态。如果状态不是DoesNotExist,则表示彩票已开始
  • 检查参与费是否满足要求
  • 如果上述两项都符合要求,则参与者将被添加至参与者列表当中,他们的参与费将被加入到奖池中
function participate() external payable {
    // We check that the lottery hasn't started yet
    if (
        randomness.getRequestStatus(requestId) !=
        Randomness.RequestStatus.DoesNotExist
    ) {
        revert("Request already initiated");
    }

    // Each player must submit a fee to participate, which is added to
    // the jackpot
    if (msg.value != PARTICIPATION_FEE) {
        revert("Invalid participation fee");
    }
    participants.push(msg.sender);
    jackpot += msg.value;
}

挑战

在上述函数中,我们检查了彩票尚未开始,但是如果我们想要了解彩票的明确状态呢?请创建一个函数来解决此问题并返回彩票状态。

添加逻辑以启动彩票和请求随机数

启动彩票的逻辑包含一个重要的组件:请求随机数。如上所述,只有彩票合约的所有者才有权限开启彩票抽奖。这样一来,所有者需要为请求提交执行费用。

startLottery函数将包含以下逻辑:

  • 检查彩票是否尚未开始,操作方式如participate函数所示
  • 检查是否达到要求的参与者数量
  • 检查执行费用是否满足最低要求
  • 检查合约余额是否足够支付保证金。还记得构造函数是如何接受请求保证金的吗?保证金将存储于合约中直到此函数被调用
  • 如果上述条件均返回true,我们将通过随机数预编译连同履行费用一起提交随机数请求。根据随机数来源,随机数预编译的requestLocalVRFRandomWordsrequestRelayBabeEpochRandomWords函数将和以下参数一起被调用:
    • 接收多余费用退款的地址
    • 履行费用
    • 履行请求的gas上限
    • salt,这是一个字符串,与随机数种子(randomness seed)共同使用以获取不同的随机词。globalRequestCount用于确保独特性
    • 请求随机词的数量,基于挑选的获胜者数量设定
    • (仅支持本地VRF)延迟时间,在履行请求之前必须通过的区块数量

由于彩票功能仅限所有者调用,因此我们也需要添加onlyOwner修饰符来要求msg.senderowner

function startLottery() external payable onlyOwner {
    // Check we haven't started the randomness request yet
    if (
        randomness.getRequestStatus(requestId) !=
        Randomness.RequestStatus.DoesNotExist
    ) {
        revert("Request already initiated");
    }
    // Check that the number of participants is acceptable
    if (participants.length < MIN_PARTICIPANTS) {
        revert("Not enough participants");
    }
    if (participants.length >= MAX_PARTICIPANTS) {
        revert("Too many participants");
    }
    // Check the fulfillment fee is enough
    uint256 fee = msg.value;
    if (fee < MIN_FEE) {
        revert("Not enough fee");
    }
    // Check there is enough balance on the contract to pay for the deposit.
    // This would fail only if the deposit amount required is changed in the
    // Randomness Precompile.
    uint256 requiredDeposit = randomness.requiredDeposit();
    if (address(this).balance < jackpot + requiredDeposit) {
        revert("Deposit too low");
    }

    if (randomnessSource == Randomness.RandomnessSource.LocalVRF) {
        // Request random words using local VRF randomness
        requestId = randomness.requestLocalVRFRandomWords(
            msg.sender,
            fee,
            FULFILLMENT_GAS_LIMIT,
            SALT_PREFIX ^ bytes32(globalRequestCount++),
            NUM_WINNERS,
            VRF_BLOCKS_DELAY
        );
    } else {
        // Requesting random words using BABE Epoch randomness
        requestId = randomness.requestRelayBabeEpochRandomWords(
            msg.sender,
            fee,
            FULFILLMENT_GAS_LIMIT,
            SALT_PREFIX ^ bytes32(globalRequestCount++),
            NUM_WINNERS
        );
    }
}

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

添加逻辑以履行随机数请求

在这一部分,我们将添加履行请求和处理请求结果的两个函数:fulfillRequestfulfillRandomWords

fulfillRequest函数将调用随机数预编译的fulfillRequest函数。在调用此函数时,会在下面调用随机数消费者的rawFulfillRandomWords函数,这将验证调用来自随机数预编译。从那里,调用随机数消费者合约的fulfillRandomWords函数,并使用区块的随机数结果和给定的salt计算请求的随机词,然后将其返回。如果请求成功完成,将发出FulfillmentSucceeded事件;反之,将发出FulfillmentFailed事件。

对于已完成的请求,执行费用将从请求费用中退还给fulfillRequest的调用者。然后,任何多余的费用和请求保证金将转移给指定退款地址。

fulfillRandomWords函数定义回调函数pickWinners,其负责处理完成请求。在本示例中,回调函数将使用随机词挑选获胜者并支付奖励。fulfillRandomWords函数的签名必须与随机数消费者的fulfillRandomWords函数的签名一致。

function fulfillRequest() public {
    randomness.fulfillRequest(requestId);
}

function fulfillRandomWords(
    uint256 /* requestId */,
    uint256[] memory randomWords
) internal override {
    pickWinners(randomWords);
}

我们将在下一部分为pickWinners函数创建逻辑。

挑战

如果在请求履行之前,因为gas价格变化很大而导致此函数失败?目前我们无法增加履行费用,请创建一个函数来解决此问题并允许我们增加履行费用。

添加逻辑以挑选彩票获胜者

彩票合约的最后一步是创建pickWinners函数,如上所述,该函数负责使用随机词为彩票抽奖挑选获胜者。

pickWinners函数包含以下逻辑:

  • 确定获胜者数量。如果您修改了NUM_WINNERSMIN_PARTICIPANTS的数量时必须设置此值,因为NUM_WINNERS需大于MIN_PARTICIPANTS
  • 根据奖池中的数量和获胜者总人数计算获胜者的奖金数量
  • 使用随机词确定获胜者
  • 为每位获胜者分发奖励,确保在分发之前将奖励从奖池中移除
// This function is called only by the fulfillment callback
function pickWinners(uint256[] memory randomWords) internal {
    // Get the total number of winners to select
    uint256 totalWinners = NUM_WINNERS < participants.length
        ? NUM_WINNERS
        : participants.length;

    // The amount distributed to each winner
    uint256 amountAwarded = jackpot / totalWinners;
    for (uint32 i = 0; i < totalWinners; i++) {
        // This is safe to index randomWords with i because we requested
        // NUM_WINNERS random words
        uint256 randomWord = randomWords[i];

        // Using modulo is not totally fair, but fair enough for this demo
        uint256 index = randomWord % participants.length;
        address payable winner = payable(participants[index]);
        delete participants[index];
        jackpot -= amountAwarded;
        winner.transfer(amountAwarded);
    }
}

恭喜您!您已经完成了创建Lottery.sol合约的整个过程了!您可以在gitHub上查看Lottery.sol合约的完整版本。请注意,此合约仅用于演示目的,不可用于生产环境。

挑战

您可以在开始创建彩票、选择获胜者或分配奖励给获胜者时添加一些事件,以便让合约更易于使用。

与彩票合约交互

现在,我们已经了解并创建了彩票合约,接下来可以开始部署并启动彩票抽奖。

编译和部署彩票合约

要编译合约,您可以简单运行:

npx hardhat compile

Compile the contracts using Hardhat's compile command.

编译后,将会创建artifacts目录:这将存放合约的字节码和元数据,即.json文件。建议您将此目录添加至.gitignore

在开始部署Lottery.sol合约之前,我们需要创建一个部署脚本。

您可以为脚本创建一个新目录并命名为scripts,然后向其添加名为deploy.js的新文件:

mkdir scripts && 
touch scripts/deploy.js

我们可以使用ethers编写部署脚本。我们将使用Hardhat运行此脚本,因此无需导入任何其他库,只需简单执行以下步骤:

  1. 使用getContractFactory函数创建一个彩票合约的本地示例
  2. 使用随机数预编译的requiredDeposit函数获取随机数请求所需的保证金
  3. 使用存在于本实例中的deploy函数以实例化智能合约。您可以传入0以使用本地VRF随机数或者传入1以使用BABE epoch随机数。在本示例中,我们使用的是本地VRF随机数。我们也需要在部署时提交保证金
  4. 使用deployed等待部署
  5. 部署好后,我们可以使用合约实例获取合约地址
async function main() {
  // 1. Get the contract to deploy
  const Lottery = await ethers.getContractFactory('Lottery');

  // 2. Get the required deposit amount from the Randomness Precompile
  const Randomness = await ethers.getContractAt(
    'Randomness',
    '0x0000000000000000000000000000000000000809'
  );
  const deposit = await Randomness.requiredDeposit();

  // 3. Instantiate a new Lottery smart contract that uses local VRF
  // randomness and pass in the required deposit
  const lottery = await Lottery.deploy(0, { value: deposit });
  console.log('Deploying Lottery...');

  // 4. Waiting for the deployment to resolve
  await lottery.deployed();

  // 5. Use the contract instance to get the contract address
  console.log('Lottery deployed to:', lottery.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

要部署彩票合约,我们将使用run命令并将moonbase指定为网络:

npx hardhat run --network moonbase scripts/deploy.js

您也可以使用其他Moonbeam网络,请确保指向正确的网络。网络名称需要与hardhat.config.js中定义的保持一致。

几秒钟后,合约成功部署,您将在终端看到合约地址。保存合约地址,我们将在下一部分中用于合约实例交互。

Deploy the Lottery contract using Hardhat's run command.

创建脚本以与彩票合约交互

我们可以继续使用我们的Hardhat项目,另外创建脚本来与彩票合约交互并调用它的一些功能。例如,我们可以在scripts目录中创建另一个脚本来参与彩票抽奖:

touch participate.js

然后,我们可以添加以下代码,这将使用合约名称和合约地址创建彩票合约的实例。接下来,我们可以直接从合约获取参与费用和调用合约的participate函数:

async function participate() {
    const lottery = await ethers.getContractAt('Lottery', 'INSERT-CONTRACT-ADDRESS');

    const participationFee = await lottery.PARTICIPATION_FEE();
    const tx = await lottery.participate({ value: participationFee });
    console.log('Participation transaction hash:', tx.hash);
}

participate()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error);
        process.exit(1);
    });

要运行此脚本,您可以使用以下命令:

npx hardhat run --network moonbase scripts/participate.js

交易哈希将在后台显示。您可以使用哈希在Moonscan查看交易。

Run the partipation script using Hardhat's run command.

这样就可以了!您可以继续创建另外的脚本来执行彩票的后续步骤,例如启动彩票抽奖和挑选获胜者。

本教程仅用于教育目的。 因此,不应在生产环境中使用本教程中创建的任何合约或代码。

本网站的所有信息由第三方提供,仅供参考之用。Moonbeam文档网站(https://docs.moonbeam.network/)上列出和描述的任何项目与Moonbeam立场无关。Moonbeam Foundation不保证网站信息的准确性、完整性或真实性。如使用或依赖本网站信息,需自行承担相关风险,Moonbeam Foundation不承担任何责任和义务。这些材料的所有陈述和/或意见由提供方个人或实体负责,与Moonbeam Foundation立场无关,概不构成任何投资建议。对于任何特定事项或情况,应寻求专业权威人士的建议。此处的信息可能会包含或链接至第三方提供的信息与/或第三方服务(包括任何第三方网站等)。这类链接网站不受Moonbeam Foundation控制。Moonbeam Foundation对此类链接网站的内容(包括此类链接网站上包含的任何信息或资料)概不负责也不认可。这些链接内容仅为方便访客而提供,Moonbeam Foundation对因您使用此信息或任何第三方网站或服务提供的信息而产生的所有责任概不负责。
Last update: September 22, 2023
| Created: April 20, 2023