
WTF Solidity 极简入门第 39 讲 链上随机数生成——从 keccak256 伪随机到 Chainlink VRF打造 tokenId 随机铸造的 NFT【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本讲聚焦 Solidity 中最棘手的问题之一如何在公开透明、完全确定性的区块链上生成随机数。作为 WTF-Solidity 教程的第 39 讲我们将逐一实现并对比两条技术路线——基于keccak256哈希函数的链上伪随机数以及基于Chainlink VRF预言机的链下可验证随机数并最终用它们编写一个tokenId随机铸造的ERC721NFT 合约完整源码见 39_Random/Random.sol。读完本讲你将掌握两类随机数方案的原理、参数配置与安全边界能够在 NFT 抽签、盲盒、GameFi 等场景中做出正确的技术选型。为什么以太坊上的随机数这么难很多以太坊应用都依赖随机数NFT 随机抽取tokenId、抽盲盒、GameFi 战斗中随机分胜负等等。但在以太坊上随机数生成远没有其他编程语言那么简单根本原因在于区块链的两个固有属性公开透明public链上所有数据、所有交易对任何人都可见确定性deterministic同一个输入在任何节点上重复执行都会得到完全一致的结果。这两点决定了以太坊无法像传统编程语言那样提供一个真随机数发生器因为任何由链上公开数据推导出的随机结果理论上都是可以被提前计算与预测的。因此开发者只能另辟蹊径要么在链上利用哈希函数伪装随机性要么借助预言机在链下生成随机数再送回链上。方法一链上随机数生成哈希函数核心实现getRandomOnchain()我们可以把一些链上全局变量作为种子利用keccak256()哈希函数来获取伪随机数。哈希函数具有灵敏性输入微变、输出剧变和均一性输出在取值空间内均匀分布两大特性因此能得到看似随机的输出。教程中的getRandomOnchain()函数正是用block.timestamp、msg.sender和blockhash(block.number-1)三个全局变量作为种子/** * 链上伪随机数生成 * 利用keccak256()打包一些链上的全局变量/自定义变量 * 返回时转换成uint256类型 */ function getRandomOnchain() public view returns(uint256){ // remix运行blockhash会报错 bytes32 randomBytes keccak256(abi.encodePacked(block.timestamp, msg.sender, blockhash(block.number-1))); return uint256(randomBytes); }代码思路非常直接把三个链上全局变量用abi.encodePacked()紧凑打包喂给keccak256()得到bytes32哈希值再强转为uint256返回。仓库中的完整实现见 39_Random/Random.sol其注释还补充了一个关键提示本例只依赖区块哈希、调用者地址和区块时间想提高随机性可以再增加nonce等属性但无法从根本上解决安全问题。为什么这个方案不安全教程明确警告这个方法并不安全存在两个致命缺陷种子全部公开可以被预测block.timestamp、msg.sender、blockhash(block.number-1)都是公开变量攻击者可以提前算出随机数然后在对自己有利的时机提交交易甚至直接挑选想要的随机数结果矿工可以操纵种子矿工有能力影响blockhash和block.timestamp的取值从而让生成的随机数符合自己的利益。尽管存在这些风险由于它是最便捷、成本最低的链上随机数方案仍有大量项目方在使用其中不乏知名的meebits、loots等项目。而这些项目无一例外地遭受了攻击攻击者可以铸造任何他们想要的稀有 NFT而非随机抽取。这一点是任何打算在生产环境使用链上伪随机数的团队必须引以为戒的。方法二链下随机数生成Chainlink VRF既然链上无真随机那就换个思路在链下生成随机数再通过预言机把随机数上传到链上。Chainlink提供的VRFVerifiable Random Function可验证随机函数服务正是为此设计的——开发者支付LINK代币即可获取随机数且随机数附带密码学证明链上合约可验证其真实性。Chainlink VRF有两个版本。本讲介绍的是V2 版本它需要到官网注册并预付费Subscription订阅模式比 V1 多不少操作、gas 消耗更高但好处是取消订阅后可以取回剩余的 LINK。上图展示了 VRF 的完整工作流程智能合约发起随机数请求 → Chainlink 节点在链下生成随机数并附带证明 → VRF 合约验证证明的有效性 → 验证通过后回调用户合约交付随机数。下面我们用仓库中的 39_Random/RandomNumberConsumer.sol 合约一步步演示接入Chainlink VRF V2的全过程。第一步申请 Subscription 并转入 LINK 代币在 Chainlink VRF 官网上创建一个Subscription订阅其中邮箱和项目名都是选填的。创建完成后往该Subscription中转入一些LINK代币测试网的LINK可以从LINK水龙头faucet免费领取。订阅创建完成后会得到一个Subscription Id即代码中的subId它是后续所有请求与计费的凭据。第二步用户合约继承VRFConsumerBaseV2为了使用 VRF 获取随机数用户合约必须继承VRFConsumerBaseV2合约并在构造函数中初始化VRFCoordinatorV2Interface接口和Subscription Id。注意不同链对应的参数各不相同需要到 Chainlink 官方文档的 supported networks 页面按网络查询本教程使用Sepolia测试网// SPDX-License-Identifier: MIT pragma solidity ^0.8.21; import chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol; import chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol; contract RandomNumberConsumer is VRFConsumerBaseV2{ //请求随机数需要调用VRFCoordinatorV2Interface接口 VRFCoordinatorV2Interface COORDINATOR; // 申请后的subId uint64 subId; //存放得到的 requestId 和 随机数 uint256 public requestId; uint256[] public randomWords; address vrfCoordinator 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625; bytes32 keyHash 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c; uint16 requestConfirmations 3; uint32 callbackGasLimit 200_000; uint32 numWords 3; constructor(uint64 s_subId) VRFConsumerBaseV2(vrfCoordinator){ COORDINATOR VRFCoordinatorV2Interface(vrfCoordinator); subId s_subId; } }这里涉及一批与网络强绑定的核心参数它们在仓库源码中都有明确注释见 39_Random/RandomNumberConsumer.sol整理如下参数Sepolia 测试网示例值说明vrfCoordinator0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625Chainlink VRF Coordinator 合约地址LINK 代币地址0x01BE23585060835E02B77ef475b0Cc51aA1e0709测试网 LINK 代币地址keyHash0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c30 gwei 的 VRF 唯一标识符gas lanerequestConfirmations3最小确认块数数字越大安全性越高一般填12callbackGasLimit200_000VRF 回调的 gas 上限最大2,500,000numWords3一次请求获得的随机数个数最大500第三步用户合约申请随机数用户调用VRFCoordinatorV2Interface接口中的requestRandomWords()函数申请随机数并获得申请标识符requestId这个申请会被传递给 VRF 合约/** * 向VRF合约申请随机数 */ function requestRandomWords() external { requestId COORDINATOR.requestRandomWords( keyHash, subId, requestConfirmations, callbackGasLimit, numWords ); }关键注意点合约部署后必须把该合约地址加入到Subscription的Consumers列表中否则无法发送申请。第四至五步Chainlink 节点生成随机数并签名VRF 合约验证申请发出后Chainlink 节点在链下生成随机数同时用其私钥生成一个数字签名将随机数与签名一并发送给 VRF 合约。VRF 合约负责验证签名有效性——只有证明随机数确实由该 Chainlink 节点生成且未被篡改随机数才会被接受。第六步用户合约接收并使用随机数VRF 合约验证签名有效之后会自动调用用户合约的回调函数fulfillRandomWords()把链下生成的随机数发送过来。用户必须把消耗随机数的逻辑写在这个回调函数里/** * VRF合约的回调函数验证随机数有效之后会自动被调用 * 消耗随机数的逻辑写在这里 */ function fulfillRandomWords(uint256 requestId, uint256[] memory s_randomWords) internal override { randomWords s_randomWords; }特别提醒申请随机数时调用的requestRandomWords()与 VRF 返回随机数时自动调用的fulfillRandomWords()是两笔不同的交易——前者的调用者是用户合约后者的调用者是 VRF 合约两者之间通常间隔数分钟延迟因链而异。这也是后续 NFT 铸造合约中必须用映射记录谁发起的申请的根本原因。实战tokenId 随机铸造的 NFT 合约这一节我们综合运用链上与链下两种随机数实现一款tokenId随机分配的 NFT。Random合约同时继承ERC721和VRFConsumerBaseV2ERC721 基础实现见仓库 34_ERC721/ERC721.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.21; import https://github.com/AmazingAng/WTF-Solidity/blob/main/34_ERC721/ERC721.sol; import chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol; import chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol; contract Random is ERC721, VRFConsumerBaseV2{说明仓库中的 39_Random/Random.sol 使用pragma solidity ^0.8.34与仓库根目录 foundry.toml 配置的编译器版本0.8.34保持一致在 Remix 中也可以使用^0.8.21编译本合约。状态变量合约的状态变量分为两大组完整定义见 39_Random/Random.solNFT 相关totalSupplyNFT 总供给量示例中为100idsuint256[100]定长数组用于计算可供 mint 的tokenId配合pickRandomUniqueId()函数使用mintCount已经 mint 的数量。Chainlink VRF 相关COORDINATORVRFCoordinatorV2Interface接口实例用于发起随机数请求vrfCoordinatorVRF 合约地址Sepolia 测试网keyHashVRF 唯一标识符requestConfirmations确认块数callbackGasLimitVRF 回调 gas 上限本合约设为1_000_000比消费合约的200_000高因为回调里还包含 mint 逻辑numWords请求的随机数个数本合约设为1subId申请的Subscription IdrequestId申请标识符requestToSendermapping(uint256 address)记录每个 VRF 申请标识符对应的用户minter地址。// NFT相关 uint256 public totalSupply 100; // 总供给 uint256[100] public ids; // 用于计算可供mint的tokenId uint256 public mintCount; // 已mint数量 // chainlink VRF参数 VRFCoordinatorV2Interface COORDINATOR; address vrfCoordinator 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625; bytes32 keyHash 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c; uint16 requestConfirmations 3; uint32 callbackGasLimit 1_000_000; uint32 numWords 1; uint64 subId; uint256 public requestId; // 记录VRF申请标识对应的mint地址 mapping(uint256 address) public requestToSender;构造函数构造函数同时初始化两个父合约VRFConsumerBaseV2传入vrfCoordinator地址和ERC721传入 NFT 名称与代号 WTF Random / WTFconstructor(uint64 s_subId) VRFConsumerBaseV2(vrfCoordinator) ERC721(WTF Random, WTF){ COORDINATOR VRFCoordinatorV2Interface(vrfCoordinator); subId s_subId; }核心算法pickRandomUniqueId()——不初始化数组的随机不重复取号除构造函数外合约共定义了 5 个函数其中最关键的是pickRandomUniqueId()它负责把随机数映射为尚未被 mint 的 tokenId/** * 输入uint256数字返回一个可以mint的tokenId * 算法过程可理解为totalSupply个空杯子0初始化的ids排成一排每个杯子旁边放一个球编号为[0, totalSupply - 1]。 每次从场上随机拿走一个球球可能在杯子旁边这是初始状态也可能是在杯子里说明杯子旁边的球已经被拿走过则此时新的球从末尾被放到了杯子里 再把末尾的一个球依然是可能在杯子里也可能在杯子旁边放进被拿走的球的杯子里循环totalSupply次。相比传统的随机排列省去了初始化ids[]的gas。 */ function pickRandomUniqueId(uint256 random) private returns (uint256 tokenId) { //先计算减法再计算, 关注(aa)区别 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 }源码注释中的空杯子与球比喻精准地解释了算法精髓把totalSupply个初始值为 0 的ids元素看作一排空杯子每个杯子旁放着一个编号为[0, totalSupply-1]的球。每次 mint 时用random % len在剩余可取范围内取一个随机下标randomIndex若该下标处ids[randomIndex] ! 0说明该杯子里已经放过从末尾挪来的球直接取出作为 tokenId否则说明是初始状态tokenId 就是下标本身把末尾位置的球可能是杯子里的值ids[len-1]也可能是下标len-1搬进刚取走球的位置将末尾元素清零。这种做法的巧妙之处在于相比先初始化完整的ids数组再随机打乱的传统做法它零初始化成本、省去了大量 gas同时利用末尾补位保证了每次取出的 tokenId 绝不重复直到 100 个全部 mint 完毕len 0时由require(len 0, mint close)拦截。三种铸造入口mintRandomOnchain()、mintRandomVRF()与fulfillRandomWords()有了取号算法两条随机数路线就殊途同归了1. 链上伪随机铸造mintRandomOnchain()——同步完成一步到位// 利用链上伪随机数铸造NFT function mintRandomOnchain() public { uint256 _tokenId pickRandomUniqueId(getRandomOnchain()); // 利用链上随机数生成tokenId _mint(msg.sender, _tokenId); }调用者即铸造者getRandomOnchain()当场算出伪随机数pickRandomUniqueId()立即返回 tokenId_mint(msg.sender, _tokenId)同步完成铸造整个过程发生在一笔交易内。2. 链下 VRF 铸造mintRandomVRF()——两笔交易需要状态暂存/** * 调用VRF获取随机数并mintNFT * 要调用requestRandomness()函数获取消耗随机数的逻辑写在VRF的回调函数fulfillRandomness()中 * 调用前需要在Subscriptions中转入足够的Link */ function mintRandomVRF() public { // 调用requestRandomness获取随机数 requestId COORDINATOR.requestRandomWords( keyHash, subId, requestConfirmations, callbackGasLimit, numWords ); requestToSender[requestId] msg.sender; }这里有个必须处理的关键问题消耗随机数的逻辑mint写在 VRF 回调函数fulfillRandomWords()里而回调函数的调用者是 VRF 合约不是发起铸造的用户。因此合约必须用requestToSender[requestId] msg.sender先把申请标识与用户地址绑定等随机数回来后才能知道该把 NFT 铸造给谁。3. VRF 回调fulfillRandomWords()——由 VRF 合约自动调用/** * VRF的回调函数由VRF Coordinator调用 * 消耗随机数的逻辑写在本函数中 */ function fulfillRandomWords(uint256 requestId, uint256[] memory s_randomWords) internal override{ address sender requestToSender[requestId]; // 从requestToSender中获取minter用户地址 uint256 tokenId pickRandomUniqueId(s_randomWords[0]); // 利用VRF返回的随机数生成tokenId _mint(sender, tokenId); }从requestToSender中取出申请人地址取s_randomWords[0]第一次请求返回的第一个随机数作为pickRandomUniqueId()的输入生成 tokenId最后_mint(sender, tokenId)完成铸造。至此链上/链下两种随机数的铸造流程闭环。Remix 全流程验证教程在 Remix 中完成了端到端验证核心步骤可复现如下1. 申请 Subscription在 Chainlink VRF 官网创建一个Subscription参考上面第二步的截图。2. 领取测试代币利用 Chainlink 水龙头获取 Sepolia 测试网的LINK和ETHETH用于支付交易 gasLINK用于支付 VRF 服务费。3. 转入 LINK在Subscription中转入一定数量的LINK代币用于后续支付随机数请求费用。4. 部署合约并用链上随机数铸造在 Remix 中编译部署Random合约构造函数传入subId点击左侧橙色函数mintRandomOnchain在弹出的钱包中确认交易链上随机数铸造即刻完成。5. 用 Chainlink VRF 铸造先把部署好的合约地址添加到Subscription的Consumers列表中否则无法发起请求然后点击mintRandomVRF并在钱包中确认。注意使用 VRF 铸造 NFT 时发起交易与铸造成功发生在不同的区块——因为随机数要等 Chainlink 节点在链下生成、签名并回传后才落地。6. 验证铸造结果通过区块链浏览器确认 NFT 已被铸造。教程示例中链上随机数铸造出了tokenId87的 NFTVRF 则铸造出了tokenId77的 NFT两条路线均验证成功。此外当合约不再使用时可以在 Chainlink VRF 上取消订阅取回剩余的 LINK 代币——这是 V2 订阅模式优于 V1 的典型特性。总结如何为你的项目选择随机数方案在 Solidity 中生成随机数远比其他编程语言复杂。本讲给出了两条完整路线链上随机数哈希函数实现简单、零外部依赖、gas 成本低、铸造过程一步完成但种子公开可预测且可被矿工操纵不适合任何涉及资金或稀缺资源的场景仅可用于对安全性要求极低的应用链下随机数Chainlink VRF随机数由链下节点生成并附密码学证明、链上验证安全性高、结果可验证但依赖第三方预言机服务需要注册订阅、预付费、gas 成本更高且铸造分为两笔交易、存在延迟。项目方需要根据业务场景权衡取舍对公平性有硬性要求如稀有 NFT 抽取、GameFi 随机胜负务必选择 VRF 这类可验证方案而原型演示或非价值敏感场景可用链上伪随机快速实现。除了上述方案社区也在探索新的 RNGRandom Number Generation形态例如randao提出以 DAO 模式提供链上真随机服务值得持续关注。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考