近期,我们宣布了Neoverse NFT收集活动,通过开盲盒生成N3元素碎片,而合成9款不同的元素碎片,即可合成一款由国际知名NFT艺术家专门打造的N3典藏版NFT。

那么这项收集活动的玩法设计逻辑是什么?共9款N3典藏版的生成规则又是怎样的呢?

这篇文章中,NGD参与此次活动的开发者将与大家分享Neoverse NFT算法设计和合约开发的一些有趣细节。

算法设计

  1. 开盲盒游戏怎么玩?

一共有27000个盲盒,一个盲盒的价格为2GAS,买十送二。开盲盒的意思是将盲盒销毁,随机获得N3元素碎片。碎片共有9种,各自编号独立,都是从1到3000。例如Fragment A #733、Fragment I #2314等。

用户合成全部9种碎片后,可以获得一个N3典藏版NFT,该典藏版NFT的种类由合成它的9种碎片的序列号之和决定。序列号之和小于4816则为N系列,大于4816且小于6411为E系列,其余为O系列。

  1. 为什么是9个碎片合成一个NFT?

九种碎片分别代表了Neo的9个属性:Interoperability, Native oracles, Self-sovereign ID, Decentralized storage, Neo name service, One block finality, Best-in-class tooling, Smart contracts, Multi-language。

九种N3元素碎片合成一个N3典藏版NFT,体现了Neo“All in ONE”的理念。

  1. 4816和6411是怎么算出来的?

在讨论碎片背后的数学逻辑之前,我们先介绍一下其市场逻辑。

一级市场:碎片将通过空投和拍卖进行随机分发。

二级市场:碎片可以在NFT交易平台上交易。

其中,市场活跃度的大小将会直接影响凑出的碎片序列号之和的大小。根据算法模拟,在市场交换最充分的时候,N系列号为900;在市场交换最不充分的时候,N系列号为8731。因此,如果我们假设市场活跃度为中等,则其均值为4816。同理,可以算出E系列号的均值为6411。

合约开发

盲盒游戏最重要的部分是随机性。既然涉及到随机性,一定有人问你们的随机性是怎么实现的,是否公平,能不能被预测,能不能被黑客利用等。

我们通过以下几个版本的合约来逐步分析,一步步找到最佳的开盲盒方案。

青铜版本:

在开盲盒的时候,取当前区块的Nonce(在N3的区块中,有一个字段叫Nonce,它是随机的,但是是固定不变的),作为随机数种子,再对3000取模+1,获得1~3000的随机数。

这样操作看起来很简单直接,但是存在很多问题:

1、同一个区块所开的盲盒都是相同的结果

2、取出的随机数可能有重复

白银版本:

在这个版本中,针对上个版本的1号问题进行了修复。首先想到的是将区块的Nonce和交易ID进行异或操作,获得随机数的种子,但这样在一笔交易中开出的盲盒又是相同的结果,显然也是不行的。然后想到对盲盒的TokenId进行哈希运算,将其转为大整数,并与当前区块进行异或操作,获得随机数的种子。因为每个盲盒的TokenId是不同的,哈希自然也是不同的,与Nonce进行异或操作,可避免每次都产生同一个随机数。但是NGD工程师黎工表示直接使用当前区块的Nonce会有一些被人利用的风险:

1、黑客可以发布一个合约A,在合约A中调用开盲盒的方法,并且对开盲盒的结果进行判断,如果发现开盲盒的结果不满意则抛出异常,中断合约执行。

2、黑客可以通过在开盲盒的脚本后面追加精心构造的OpCode,完成上面的步骤。

这两点在前一版本中也同样存在。

黄金版本:

在这个版本里,我们将随机数放在买盲盒之后,开盲盒之前。这样当你买盲盒的时候无法预测将来的区块Nonce,当你开盲盒的时候,结果是确定的。

具体操作如下。购买盲盒时,记录下下一个区块的索引。当你开盲盒时,取那个区块的Nonce并和TokenId的哈希进行异或。

这样就避免了黑客的攻击,但是针对青铜版本的第二个问题,仍没有解决。两个盲盒可以开出同样的碎片,比如 Fragment A #33

铂金版本:

在这个版本里,我们主要解决随机数重复的问题,有几种解决方案:

1、存储区中存储所有未被使用的随机数(最多27000个),每用到一个,就将其删除。下个人取随机数时,读取存储区中的随机数数组,从中取随机数。

2、存储区中存储所有已使用的随机数(最多27000个),每取一个随机数,就将其存到存储区里。下个人取随机数时,读取存储区中的随机数数组,找到未使用的随机数,从中取随机数。

以上两种方案其实是等价的,无论存储区中存储已使用的,还是未使用的随机数,都要大量使用存储区。按每个随机数2字节计算,一共54KB的数据,写入一次约13.5GAS,手续费是相当高昂的。

那么如果用位(Bit)存储呢,一个长度为3375的字节数组,每一位的下标表示随机数本身,每一位的值(0或1)表示该随机数是否使用。再结合分片操作,将9种碎片的随机数分开存储。这样读取写入的费用会大大降低,但考虑到将位还原为数组,会有大量计算,手续费仍然不很乐观。NGD工程师印工提出了一种新的解决方案。针对每种碎片的1~3000的随机数,存储区中存储最后一个随机数的下标,和抽取替换的过程。具体来说,假设用户抽取了下标为500的随机数,则把500给他,并在存储区中记录k:500,v:3000,表示下标500的位置存放的是随机数3000。然后将随机数下标的最大值3000更改为2999。第二个用户又抽取到下标为500的随机数,检查存储区,将下标500位置的随机数3000给用户,并更新存储区k:500,v:2999。然后将随机数下标的最大值2999更改为2998。依此类推。

随机数生成的描述为:第一个人,Nonce模3000+1第二个人,Nonce模2999+1;第三个人,Nonce模2998+1;

细心的用户又会发现,在黄金版本中,开盲盒的结果是确定的,不随着你开的先后顺序而变化,但在这个版本中,你先开和后开,结果可能会变化。这样又暴漏了另一个问题:

1、黑客可以在本地对合约进行预执行,如果结果满意,则广播交易,如果不满意,则等待其它人开出该类碎片后再进行预执行操作,直到它满意了,发送交易。

2、白银版本中的两个问题又出现了。

星钻版本:

到这里我就不得不吹一下Neo底层中的随机数生成算法的厉害之处了。其实这个随机数算法是在N3上线前加入的,我在写前几个版本的合约的时候还不能使用。NGD工程师刘工给我介绍了这个随机数算法。它能保证真随机、不可预测(预执行和链上执行的随机数不同)、每使用一次就会更改。在合约中也可以很方便地用互操作服务(Runtime.GetRandom())来使用这个随机数。结合铂金版本中印工的方案,即可每次获得1-3000中不重复的随机数。到这里,随机数部分已经近乎完美了。但是白银版本中提到的两个问题是真正在链上执行,只是增加对结果的判断,这是随机数算法所不能干预的,要通过其它安全限制来规避。我计算了一下,黑客通过这种方式开盲盒的成本比较高,初始成本为10.1GAS,之后每尝试开一次盲盒成本为1.05GAS。尝试两次都不满意的话,相当于直接浪费了一个盲盒(1个盲盒的价格为2GAS)。但即使这样,即使黑客亏本攻击,我们仍然希望他无从下手,所以有了下一个版本。

皇冠版本:

我和Neo社区工程师廖工在星钻版本上进行了改进。

1、在这个版本中添加了对合约调用的限制,只允许通过交易来调用Neoverse合约进行开盲盒操作,不允许通过其它合约进行调用。

2、针对开单个盲盒以及批量开盲盒的方法,根据实参长度精确计算所需脚本长度。不允许在标准调用脚本中附加任何一个字节的脚本。

到此合约近乎完美。