Chainlink VRF 是行业领先的安全随机数生成器(RNG),为智能合约和链下系统提供可验证且防篡改的随机数来源。本文作者 Xing 则以开盲盒为例详解 ChainLink 的 VRF 服务是如何保证随机性以及可验证性的。

原文:ChainLink VRF 技术分享之 Zombie 开盲

作者:Xing  丨 Web3Caff 经授权发布

前言

Zombie NFT 终于开盲盒了,与别的大多数 NFT 项目开盲盒方式不同,Zombie 采用了 ChainLink VRF 技术作为开盲盒的随机数,鄙人开了 4 只,全是普通 Zombie,虽然是挺有意思的开盲盒体验,但还是决定花点时间研究下 VRF 随机数的工作机制,看看究竟为什么我还是没有开到 1/1 的 Zombie?

4 Zombies

第一次听到 VRF(Verifiable Random Function)是一个大佬私信问我,那时我第一次听说 VRF,当时简单查了下,只知道是 ChainLink 提供了一个随机数服务,但是没仔细研究过 ChainLink 是如何实现可验证随机数的机制的。目前在网上查找的资料,也没有一篇文章能将 VFT 的工作机制描述清楚的,所以只能是嗑源码了。看完源码后发现确实是比较复杂,文章也不太好描述清楚,所以本人也尝试一下用简单的语言结合 Zombie 给大家做个分享。

就目前我所知道的大多数 NFT 项目,实际上并不是真正意义上的开 “盲盒”,当大家都 mint 完了之后,实际上项目方就已经知道所有人拥有的 tokenId 了。而开盲盒的时候项目方完全可以将稀有属性的 NFT 分配给指定人,只需要简单修改下 metadata 的文件名,然后再将这些文件上传到 IPFS 网络中,最后操作 setBaseURI 就行。(关于这部分的细节可以参考我这篇文章

为什么需要 on-chain 随机数

技术人员都知道,随机数的产生需要一个 seed,这个 seed 的随机性越高,那么产生的随机数就越可靠。电脑上的随机数的 seed 可以是当前系统时间(以纳秒为单位,1 纳秒=10 的负 9 次方)再加上其他的一些随机参数,例如网卡 MAC 地址、进程的 PID 等一起作为随机数种子,再通过一些固定算法就可以生成一个随机数了。

那如何能真正实现开盲盒,让这些 NFT 能够真正随机的分配给用户,并且可以被证明无法被人为地操控?这就需要在链上能找到一个随机性较强的 seed,通过算法计算出随机数,通过这个随机数给 NFT 指定 metadata 就能实现真正意义上的开盲盒了。

所以生成随机数只要找到一个随机性强的 seed 就好。鄙人的红包项目,也面临着这个问题,就是当用户打开一个红包的时候,可以从红包中领取一个随机金额。我合约里的 seed 是这样做的:

function _random(uint256 remainMoney, uint remainCount) private view returns (uint256) {
   return uint256(keccak256(abi.encode(block.timestamp + block.difficulty + block.number))) % (remainMoney / remainCount * 2) + 1;
}

可以看到,红包随机数的 seed 用到了 block.timestamp(区块时间戳),block.difficulty(区块难度),block.number(区块高度)。这三个数据是在开红包这笔交易被打包进区块的时候由矿工生成的,所以用了这三个链上的未知数据作为了 seed,用于领取红包金额的随机数计算。

如果是发红包这种小金额的应用场景问题不大,我这篇文章里也提过这个问题,因为矿工作恶成本还是很高的,收益比不划算。

但如果是涉及金额较大的 defi 合约,这样设计随机数生成就有问题了。比如一个彩票合约,如果开奖号码的 seed 还是上面几个参数,加上奖池金额的吸引力足够大,那么一个拥有大量算力的矿池就有可能故意修改上述参数以达到控制开奖号码的目的。

结合 Zombie 开盲盒的过程,VRF 大致的机制流程和逻辑分享以下:

基本流程

  1. 大家开盲盒的时候,首先会调用 Zombie NFT 的合约里的开盲盒方法(合约未上传,暂时不知道方法名),Zombie NFT 合约中开盲盒的方法会去调用 ChainLink 的 Coordinator 合约中的 requestRandomWords 方法,向 ChainLink 的 VRF 服务发起一个随机数的申请请求
  2. ChainLink 的 Coordinator 合约收到 Zombie 合约的随机数申请请求后,会根据该随机数请求的参数计算 requestId 和 preSeed,同时向区块链上发送含有此次随机数请求相关参数的 eventLog
  3. ChainLink 的 Oracle 节点会在链下监听到此次随机数申请的 Event Log 并拿到相关参数,然后会用自己的私钥对此次的随机数请求生成一个证明(Proof),并将该 Proof 提交回链上 ChainLink 的 Coordinator 合约中
  4. ChainLink 的 Coordinator 合约会校验 Proof 的合法性,如果链下节点提交的 Proof 合法性被 ChainLink 的 Coordinator 合约校验通过,则会利用这个 Proof 中的一个加密参数(proof.gamma)作为此次随机数请求的 seed,并生成随机数
  5. ChainLink 的 Coordinator 合约用生成的随机数作为参数,又会去调用 Zombie 合约中预留好的 fulfillRandomWords 方法,然后 Zombie 合约将收到的随机数计算并分配用户某个 NFT

以上就是整个 ChainLink VRF 生成的大概过程,上述过程中第 1,2 步是在链上的合约完成的,然后第 3 步是链下的 Oracle 节点处理,完成处理后又接着调用链上合约,接着第 4,5 步又回到了链上完成处理。所以 Zombie 开盲盒的时间比较长,就是因为背后完成了两次合约的交互(中间还等待了一点确认区块时间)。

链下 oracle 监听 Coordinator 发出的 Event Log

其实 Chain Link price feed 获取链下交易价格的流程类似:链上通过 event log 广播出来所需要获取的链下数据,链下节点根据 event log 中的数据请求,获取到相应链下数据后并重新将该数据设置到链上。

如何保证链下节点不作恶,只能设置真实的链下数据到链上是一个很复杂的事情,因为数据上链前,需要严格保证链下数据是真实可靠的。大家可以想象一下,如果是一个 Defi 合约,需要获取 ETH/USDT 交易对的链下数据价格来做借贷订单的交割之类的操作, 这个链下数据的价格设置多少会涉及到大量钱的结算,所以这个设置的价格必须是无法作恶、无法作假并且是真实可靠的。如何保证这个链下价格是真实可靠的,ChainLink 有一整套机制来实现一个去中心化的链下节点校验,具体机制与鄙人 Terra 网络及稳定币经济模型简介  这篇文章中介绍的大同小异,感兴趣的可以看看 Terra 网络是如何获取链下 USD/USDT 的真实价格的。

不过 ChainLink VRF 的随机数获取的流程虽然与其 price feed 的获取流程一样,但是如何保证所取得的随机数不可预测,又与获取 price feed 的机制不一样了。

随机数 seed 是如何生成以及验证的?

VRF 名字是 Verifiable Random Function,那:

  • 随机数的不可预测性
  • 随机数的不可预测性可以被验证

这是 VRF 要解决的两个关键问题,这里简单描述下 VRF 如何保证随机性以及其是可验证的:

  1. Coordinator 合约根据随机数请求的参数,会先通过一个固定算法生成一个 preSeed,同时记录下当前这笔交易的 blockNumber,然后将 preSeed 作为 Event Log 内容发送至以太坊上
  2. ChainLink Oracle 监控到 Event Log 后,从 Event Log 中能够拿到上一步生成的 preSeed,同时还可以拿到上一步交易所在区块的 blockHash(特别注意:blockHash 只有该 block 被矿工打包之后才可以拿到,在合约中是无法拿到当前区块的 blockHash 的,只能拿到 blockNumber,所以这也是上一步 Coordinator 合约中无法记录当前区块的 blockHash,只能记录 blockNumber 的原因)
  3. 在等待指定数量的区块确认之后,ChainLink Oracle 用自己的私钥将上述包括 preSeed、blockHash 在内的参数去生成 Proof,并将 Proof 提交回 Coordinator 合约中
  4. 这时 Coordinator 合约会去验证这个 Proof,因为第一步 Coordinator 合约中记录下了当时那笔的 blockNumber,所以这时合约里就可以用 blockHash(blockNumber) 这个函数获取历史区块的 blockHash 了,blockHash 再加上自己所生成的 preSeed,就可以对 ChainLink Oracle 提交的 Proof 进行验证了
  5. 如果验证通过,就可以利用 Proof 中一个无法篡改的参数(proof.gamma)作为这个随机数的 seed。如果验证不通过,那么这次 Oracle 提交的 Proof 交易就会被回滚,这样 Oracle 节点就无法获取奖励。

所以只要使用 Chain Link 的 VRF 服务,要么你拿不到随机数,只要你能拿到就代表一定是被验证过的且不可预测的随机数。

因为 Coordinator 合约中会记录这次随机数请求的 BlockNumber 以及自己生成好的 preSeed,在该请求被打包好并且发送 log event 后,Oracle 节点才可以拿到 BlockNumber 对应的 BlockHash 以及 preSeed,并且 Oracle 节点生成的 Proof 中又包含有 BlockHash 及 preSeed 参数,所以 Coordinator 合约中就可以用自己记录的这两个数值去校验 Oracle 提交的 Proof 是否正确(校验的时候合约才能根据记录的 BlockNumber 拿到 BlockHash)。

VRF 之所以能产生不可预测的随机数 seed,主要是因为采用了两个未知因素:

  • BlockHash(在区块被矿工打包确认前是未知的,被打包之后才能确定)
  • ChainLink Oracle 提交的 proof(提交 Proof 对应的私钥是未知)

随机数的生成如果只有 BlockHash 作为 seed 来源,那么矿工可能作恶;如果只有 ChainLink Oracle 的 proof 作为来源,那么 Oracle 节点可能作恶。所以 ChainLink VRF 巧妙的结合了这两个未知来源,Oracle 节点用其私钥构建的 Proof 中包含了 BlockHash 及 preSeed 这两个参数,保证了 Proof 的不可预测性,同时将不可预测的 Proof 中的 gamma 参数作为了随机数的 seed,变相地也就保证了随机数 seed 的不可预测性了。

所以开不到 1/1 的 Zombie,主要原因是我运气太差了,哈哈。

// VRFCoordinatorV2.sol
// 将 Coordinator 中记录的 preSeed 与 blockHash 生成 actualSeed
uint256 actualSeed = uint256(keccak256(abi.encodePacked(proof.seed, blockHash)));
// 将 Oracle 节点提交的 Proof 去验证合约中记录的这个 actualSeed
randomness = VRF.randomValueFromVRFProof(proof, actualSeed); // Reverts on failure

// VRF.sol
// 验证 Proof 成功后,返回 proof.gamma 作为随机数的 seed
function randomValueFromVRFProof(Proof memory proof, uint256 seed) internal view returns (uint256 output) {
    verifyVRFProof(
      proof.pk,
      proof.gamma,
      proof.c,
      proof.s,
      seed,
      proof.uWitness,
      proof.cGammaWitness,
      proof.sHashWitness,
      proof.zInv
    );
    output = uint256(keccak256(abi.encode(VRF_RANDOM_OUTPUT_HASH_PREFIX, proof.gamma)));
 }

以上是简单描述的 VRF 机制如何保证随机数不可预测以及如何验证的过程,详细的工作机制感兴趣的话可以参考 ChainLink 合约中的代码,主要在 VRF.solVRFCoordinator.sol 中。

当然使用 VRF 还有注册、付费等操作,包括 VRF 对 Oracle 节点也有激励惩罚机制,这里就不展开讨论了。

关于真随机

就上述 VRF 的工作机制,我记得 ZombieClub 开展过一个讨论,题目是 “世界上存不存在真随机?”。

当时我的回答是 “这其实是一个物理问题”,就是计算机上产生的一切因子作为随机数 seed 的话,只要刨根问底都是可以被预测的,即便是上述 VRF 的方案。包括像风、潮汐、天体运动等等这些物理现象,实际上目前都能被公式给推导出来,所以这些自然现象也无法作为真随机的 seed。

所以什么是目前人类无法推导计算出来的呢?实际上我也不知道,各位可以发散思考下。

所以随机性是个相对概念,目前并没有绝对的真随机,所以采用什么随机数的方案需要根据你的项目的资金大小和对随机性的容忍度来确定。

例如鄙人红包 DAPP 中获取随机金额的算法就没有那么随机,因为用到的都是链上数据。但是我觉得这个没关系,因为红包 DAPP 的应用就是小金额的赠予,所以相对于收益作恶成本实在是太高了,不会有矿工为了那一点红包的钱去作恶。

同样对于 Zombie 这次使用 VRF 进行开盲盒,从技术上来考量的话,鄙人觉得也无必要。因为如果有矿工作恶的话,收益无外乎就是几只属性稀有的 NFT,相比起他们为作恶这事付出的成本以及正常挖矿带来的收益,实在是可以忽略不计。但如果考虑到 Zombie 团队对整个项目的品质要求,以及采用高成本开盲盒方案所带来的市场影响力的话,也许在 Marketing 方面会有很大的话题性以及区分度。

关于 gasLimit

这里再提一下 Zombie 公售时,很多朋友遭遇 out of gas 的问题而损失所有 gas。这个问题的根源因为合约还未上传,所以无法确定。目前能确定就是公售和预售都使用了合约中的同一个方法(鄙人这篇推特中有提到),在同一个方法中的执行路径不同,导致使用的 gas limit 不同,实际上公售和预售分成两个方法就好了。

在 TeaHouse 的 AMA 中,项目方人员解释到为什么不把公售和预售在合约中分为两个方法实现的原因大概是:“因为会有 CDN 缓存,担心用户在公售时抢 mint 还有缓存,本来应该调用合约中公售方法的时候,因为缓存的原因可能会去调用合约中预售的方法去了。”

当时 AMA 听到一个外国哥们建议 Zombie 将用户损失的 gas 返还,当时我点了赞成的表情,并且举手想讨论一下是否有更好的方案可以避免这个损失。当时我想说的是:如果在合约中将公售和预售分为两个方法,同时前端网页上也写好两个公售和预售方法的调用实现,并且在签名 API 返回的结果中加入一个 type 来标识此时是公售还是预售,如果 type 是公售类型的话,前端拿到这个结果就调用前端之前写好的公售方法就好了,这样也不用担心 CDN 缓存导致问题。

在写合约里方法实现的时候,敏感的执行尽量不要依赖外部因素(例如 block.timestamp),并且不要使用不确定执行次数的循环,否则外部因素变化会导致执行路径不同,那预估的 gas limit 就会不一样,从而有可能导致用户的 gas 损失。

这次开盲盒之前,我其实还挺担心 out of gas 问题的,因为 ChainLink 设置一个随机数回到合约后,这个随机数对应的 NFT 可能被别人开了,这时合约里就得在循环里去找下一个数,一直找到未被开的那个 NFT 号码为止,这就又出现了之前说的不确定执行次数的循环,当循环次数过多可能就又会导致 out of gas 问题,虽然回调的 gas 是由 Zombie 团队支付了,但是用户付的开盒请求的 gas 又白付了。

但还好,Zombie 开盲盒过程没有出现这个问题,我能想到的一个方案是先将所有候选 NFT 号码作为 list 存入合约,每次计算的随机数范围就是这个 list 的长度,开一次盲盒就用该随机数作为 index 去 remove 一个 NFT 的号码,这个方法部署合约成本比较高,因为需要先存一个 list 到合约中,但可以避免掉上面那种通过不定长的循环去找剩余可用 NFT 号码方案了。

最近 Pak 的 Metamorphosis 系列 NFT 在公售时也遇到了 out of gas 问题,原因主要也是因为使用了不定长的循环,我这篇推文有详细描述,合约开发者可以注意一下。

关于技术方案的确定及选用,肯定是项目团队基于条件深思熟虑后得到的,实际上 TeaHouse 已经做的很好了,包括科学家的防范,包括公开 mint 时候的流畅程度。虽然有一点公售时 out of gas 的瑕疵,但是就像乐哥说的,希望大家都继续向前看吧。

最后

祝愿 Zombie 越来越好!

免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。