Xing

Posted on Mar 30, 2022Read on Mirror.xyz

ChainLink VRF技术分享之Zombie开盲盒

前言

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还是上面几个参数,加上奖池金额的吸引力足够大,那么一个拥有大量算力的矿池就有可能故意修改上述参数以达到控制开奖号码的目的。

ChainLink VRF的工作机制

结合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号码方案了。

2022-04-02更新

上面说的开盲盒方法,需要提前存一个list到合约中,实际上也可以不用。可以借鉴一下ERC-721Enumerable中的_removeTokenFromOwnerEnumeration()做法,为每个待开的所有token存一个指针,每开出一个盲盒,就把该tokenId的与最后一个tokenId交换,并且删除最后一个tokenId。这样就可以保证每次随机数都可以与这个数组长度取余,永远保证单次都能拿到剩下的tokenId。

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

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

最后

祝愿Zombie越来越好!

Chainlink