以太坊solidity迁移flow cadence指南11 ---NFT盲盒应用

NIST Warning:阅读本文,需要至少幼儿园中班数学水平,要能数到10!对那些只会1以内加法的码农,请在有经验的码农陪同下观看本文。

背景知识

本节主要介绍cadence随机数在盲盒中的应用。

盲盒玩法是很多NFT的首选,从NBA Top Shot 到冰墩墩,都是如此。区块链盲盒最大的魅力就在于其“公平、公正、公开”的随机性。而如何保证这个“随机性”,则是盲盒合约编写的关键。

这里就先介绍一点点数学背景知识:

首先,计算机基本是无法产生真正“随机数”的,主要是计算机的精度总是有限的。当然,计算机可以产生在大家有生之年,无法破解其规律的随机数,也就是“伪随机数”,目前大部分编程语言生成的都是这类随机数。

再者,去中心化的区块链上产生“随机数”,比一般的web2.0服务器上更加困难。主要是因为区块链是分布式的,所有节点需要对链上状态改变达成共识,也就是说,交易在所有节点上的计算结果都是一样的。如果存在随机的操作码,则不同节点将获得不同的结果,网络就没法达成共识。

基于上述原因,以太坊Solidity本身是不内置随机数函数的,这就需要自己设计随机数。目前最常见的思路,就是基于时间等不断变化、不可预测的要素,使用哈希等运算,生成一个非常大的整数,作为随机数。

Solidity一个基本的的生成随机数的方法如下所述:

uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty)))

其中:block.timestamp 为区块的时间

      block.difficulty 为区块的难度

链上生成随机数的核心是在交易被打包到区块之前,尽可能的选取不可预测的种子(数)来生成随机数。

上面这两个要素对普通用户而言是无法预测的,也就是说在一笔交易中,这笔交易什么时候发生,被谁打包到区块中,对用户来说是不可知的,但是一旦被打包到区块中,这些值就是确定的了。因此,上面这种随机数产生方式是基本可行的。

当然,如果有足够的利益驱动,节点是可以持续对区块进行打包,直到计算出对自己有利的随机数,才打包区块,也是可以的,但这样一般是得不偿失的,除非奖池有一个小目标。

Cadence中也有类似的函数:

fun getCurrentBlock(): Block

pub struct Block {

// hash of the block.

   pub let id: [UInt8; 32]   

    //The height of the block.

    pub let height: UInt64

    // The timestamp of the block.

     pub let timestamp: UFix64

}

可以按照类似的方法,使用flow区块的height、id、timestamp构造Cadence的随机数函数, 一个简单的实现如下所示:

let my_block = getCurrentBlock()

let cur_time = my_block.timestamp

let cur_time_data: [UInt8]  = cur_time.toBigEndianBytes() //  `[73, 150, 2, 210]`

let data_rand = HashAlgorithm.KECCAK_256.hash(cur_time_data) //[UInt8]

data_rand就是生成的256位随机数,因为cadence函数生成的是32个UInt8的数组,自己根据位数转换成Uint256即可。

上述的方法相对比较简单,但有个明显的不足之处,就是一个区块中产生的“随机数”都是一样的,如果区块时间比较长,一段时间内大家可能都抽到了1等奖@#¥#@!@。Solidity中的也有一些改进方法,主要是加入更多业务相关的随机要素,好比用户的钱包地址等,然后再进行多次哈希运行等。

由于众多web3.0应用都依赖于随机数,因此,就有一些专门做区块链随机数的团队,典型的如Chainlink开发的可验证随机函数(VRF),其利用去中心化的预言机来生成链上随机数,其API也是提供一个256位的随机整数。

当然,Flow的Cadence中就有自带的随机函数:

fun unsafeRandom(): UInt64

太赞了!笔者大致看了Flow Go的源代码,产生随机数方式和solidity产生随机数的基本思路应该是一致的(不完全确定)。由于flow的区块时间比较短(秒级),因此,大部分场景下,直接使用这个函数就行了,下面就大致讲一些不同的NFT场景,怎么应用这个函数。

因为unsafeRandom()生成的是64位的随机无符号整数,一般就是一个非常大的数(例如2039449844162889432)。而NFT应用中,一般是需要一个例如0-9这样比较小的随机数,具体怎么使用这个大的随机数呢?

按照码农的一般经验,用一个非常大的随机数,生成一个小范围的随机数,用取模运算就行,如N是我们得到的一个大随机数,要获得一个0-9之间的随机数,就用10当做模。N mod 10 就能获得一个0到9的随机数。当然,在随机数足够大,而模非常小的情况下,这个方法是没有问题的。

盲盒应用

下面就基于取模方法,具体到NFT几个常见的盲盒的场景,讲一下Cadence随机函数的使用。

1 按照相同的概率抽取。这个是最常见的,就是预先铸造好全部的NFT,然后用户从中随机抽取一个。类似于转盘抽奖或者年会活动的抽奖箱。

图 1 cadence如何实现这个幸运大转盘?

具体而言,好比预先铸造了10个NFT,存放在数组nftList里面,下标 index就是从0到9。第一个用户抽取时,就需要产生一个0-9的随机数,按照前面讲的。

let n1 = unsafeRandom() // 产生一个大的随机整数

然后模10,n1 mode 10, 也就是 :

Index1 = n1%10 // 产生了一个0-9的随机数。

nftList[index1]  就是第一个用户抽取的NFT了。 

第二个用户来抽取的时候,就剩下9个nft了,就需要从数组中删除掉第一个用户抽取的NFT。当前数据下标就是0-8了,这时就需要产生一个0-8的随机数:

let n2 = unsafeRandom() // 产生一个大的随机整数

index2 = n2%9 //产生一个0到8的随机数

nftList[index2] 就是第一个用户抽取到的NFT了 。

后续的用户就依次类推即可。

我们来看一段具体的线上合约代码:

let indexPackAvailable = unsafeRandom() % UInt64(packTemplate.packsAvailable.length)

let templateIDs = packTemplate.packsAvailable[indexPackAvailable]!

packTemplate.packsAvailable.remove(at: indexPackAvailable)

第1行代码,就是用大的随机数按照数组长度取模,随机获得一个数组的下标index。这里需要注意,取模%运算符两边的类型要一致。

第2行代码,就是根据上一步取得的随机数组下标,获得对应的数组元素。

第3行代码,就是删除已经抽取的数组元素。

合约地址:

Flow View Source

类似的使用形式还有很多。像图1中的大转盘,大家可以想想怎么用Cadence实现?

2 按照不同的概率抽取。比如抽取每个物品或者属性获得概率是不一样的。具体好比还是大转盘,转到每个奖品的概率是不一样的。或者是NFT游戏中,随机生成不同稀有度的装备或者属性,典型的如魔兽世界,装备有白色(普通),绿色(优秀),蓝色(精良),紫色(史诗)和橙色(传说)几个级别,越好的,获得的概率就越低。

图 2 概率不一样的随机应用该如何设计

我们就以魔兽世界装备为例,抽取一次装备,获得各个颜色的装备概率如下:

表1:不同颜色装备的获得概率

装备颜色/稀有度

获得概率

白色(普通)

50%

绿色(优秀)

30%

蓝色(精良)

14%

紫色(史诗)

5%

橙色(传说)

1%

这个需求,用幼儿园数学水准解决,貌似还是有点难度的。

不过不要怕,我们还是从一个简单的例子看起:就只有2个颜色的装备,白色获取概率为60%,绿色获取概率为40%。

根据场景1,我们是可以获得一个0-9的随机数的,那么,我们还是用大小为10的数组存放数据,前6个位置index=[0,5]存放白色装备,后4个位置index=[6,9]存放绿色装备。那么每次随机抽取一次,“显然”,抽到白色装备的概率就是6/10=60%, 抽到绿色装备的概率就是4/10=40%。看到了吧,只要思想不滑坡,能数到9就能解决这个“复杂”的问题。

 

图3 按照不同概率进行抽取

当然,如果是类似61%,39%这样的比例分布,那就用[0,99]的数组就行。稀有度不止2类?那就把[0,99]分成更多段就行。啊?还有小数,类似14.1%,39.5%这样的小数?那就用[0,999]这样的数组就行了,也就是根据小数位不同,进行比例扩充即可。

当然,在具体代码实现的时候,可以简单一些,只需要预定义每类随机稀有度对应区间即可,也就是记录首尾两个位置,然后看随机数掉到哪个区间即可,具体代码如下所示:

//功能:给定物品的概率分布,随机获得一个物品

//输入:属性和对应的概率值,例如 {"White":0.5, "Green":0.3, "Blue":0.14,"Purple":0.05,"Orange":0.01},概率之和需要=1

//返回:按照不同属性给定概率,返回一个属性,上面输入例子返回就是 颜色,如 "Blue"

      pub fun get_rand_nft(item_prob:{String:UFix64}): String {

            let prob_list = item_prob.values

            let nft_list = item_prob.keys

            //step 1, build area

            let ratio:UFix64 = 1000.0

            var nft_area_list:[UFix64] = [0.0]

            var prob_sum:UFix64 = 0.0

            for item in prob_list {

              prob_sum = prob_sum + item*ratio

              nft_area_list.append(prob_sum)

            }

            //step 2, get index

            let big_int = unsafeRandom() //UInt64,can't run in playground, need testnet or emu

            //let big_int:UInt64 = 999923

            let base_mod = UInt64(ratio) //same to ratio

            let rand_index = UInt32(big_int % base_mod)

            var item_index = 0

            for item in nft_area_list {

              if 0.0 == item {  // 第一个不算

                continue

              }

              if UFix64(rand_index) < item {

                break

              }

              item_index = item_index + 1

            }

            let rand_nft = nft_list[item_index]

            return rand_nft

    }

这种场景更适用于链上实时、在线的生成随机NFT,特别是游戏场景,这样也显得更公平,更去中心化一些。

3 随机生成一个范围内的数值。这个主要是游戏相关的场景,还是以魔兽为例,好比一个武器的敏捷加成是22,具体生成的时候,实际是一个范围内,如[10,30]的随机值,而伤害值的上限和下限,一般也是一个范围内的随机值。

图4 产生一定范围内的随机数

还是从简单的说起,好比一个武器的敏捷加成是0到29之间的随机数, 如何设计?那就和场景1完全一样了,用内置函数生成的大随机数取 模 30即可,得到的就是0到29之间的随机数。 

当然,一般为了安慰游戏玩家,武器的属性随机数不会从0开始,大部分还会给个鼓励奖,好比敏捷加成是10到39之间的随机数。可是我们只会生成0到29的随机数啊?那就把生成的[0, 29]之间的随机数,都加上10,看看是什么,不就是[10, 39]之间的随机数吗? 具体实现代码,就按照这个思路写就行,如果要生成[n,m]之间的随机数,那就先生成[n-n, m-n], 也就是[0,m-n]之间的随机数,然后再加n就行啦:)

什么?你想生成10.1到30.6之间的随机数?好吧,居然有小数位,这时候,就要发挥大家幼儿园数学全部功力了,那就先生成101到306之间的随机数,然后除以10好了!WOW 简直堪比潘周聃了。。。

具体的代码实现如下所示:

//功能:给定一个最大值和最小值,返回两个值区间内的一个随机值,本函数精度到3位小数,如果精度要求更好,可调整ratio即可

     pub fun  get_rand_value(min_value:UFix64, max_value:UFix64): UFix64 {

              var value = 0.0

              if min_value == max_value {

                  value = min_value

                  return value

              }

              let ratio = 1000.0

              let dis = ratio*(max_value - min_value)  //ensure max_value - min_value is more than 0.001

              let big_int = unsafeRandom() //UInt64,can't run in playground, need testnet or emu

              //let big_int:UInt64 = 999923

              let base_mod = UInt64(dis + 1.0)

              let rand_value = big_int % base_mod

              let expand_value = ratio*min_value +  UFix64(rand_value)

              value = expand_value/ratio

              return  value

    }

本节内容到此结束,眼过千遍,不如手过一遍,大家还是找点时间跑一下代码,才能学的更快。

思考一下

真实的魔兽世界等游戏,随机生成规则往往比上面说的稍微更复杂一些,典型的如不同稀有度/颜色的装备, 其属性随机范围也是不一样的,好比一把剑(sword),  颜色质量的生成概率如表1所示。剑的属性有damage/伤害、agility/敏捷、intelligence/智力3个属性,不同颜色的装备,具体的属性分布如下表所示:

装备颜色/稀有度

属性

属性值范围

白色(普通)

damage/伤害

200.0,500.0

agility/敏捷

20.0, 40.0

intelligence/智力

10.0, 30.0

绿色(优秀)

damage/伤害

500.0,1000.0

agility/敏捷

40.0, 80.0

intelligence/智力

30.0, 70.0

蓝色(精良)

damage/伤害

1000.0,2000.0

agility/敏捷

80.0, 120.0

intelligence/智力

70.0, 110.0

紫色(史诗)

damage/伤害

2000.0, 2600.0

agility/敏捷

120.0, 200.0

intelligence/智力

110.0, 190.0

橙色(传说)

damage/伤害

3500.0, 3500.0

agility/敏捷

200.0, 400.0

intelligence/智力

190.0, 390.0

大家想想,基于上面的知识,如何实现上表中的内容?如果能自行实现这个,flow cadence 编程基本就略懂了。具体实现可以参考文末的github,当然,最好能自己先写一写。

另外,如果大家用flow的playground调试代码的时候,需要注意,playground是不支持运行UnsafeRandom函数的@#¥#@¥@,会报如下错误:

[Error Code: 1057] operation (UnsafeRandom) is not supported in this environment

已经和开发团队确认,目前就是这样的,大家playground调试的时候,可以先固定一个UInt64正整数,调试完成,再在虚拟机或者测试环境上调试即可。

在虚拟机调试的时候,需要注意,启动的时候设置区块生成时间-b参数:

flow emulator  -b 1s

也就是1s产生一个区块,要不然的话,区块高度会一直停留在0。你UnsafeRandom生成的随机数一直不变@#@¥¥#....

题外话

flow Cadence的 unsafeRandom()是否 safe?如果是100% safe的话,官方应该叫safeRandom了吧。。。 主要问题,还是前面说的的,在一个区块内,生成随机数的会有碰撞问题,就是如果都用一个区块的高度、id等,使用一个算法生成一个大整数,这个大整数显然就是一样的。如果一个区块时间比较长,一个区块内“随机”的NFT可能就一样了。我们可以用一个简单的随机脚本来测试以下:

pub fun main():[UInt64]{

    let my_block = getCurrentBlock()

    var rlist: [UInt64] = [UInt64(my_block.height), UInt64(my_block.timestamp)]

    var i = 0

    while i< 2 {

        rlist.append(unsafeRandom())

        i = i + 1

    }

    return rlist //返回区块高度,区块时间,两个随机数

}

多次测试的结果如下所示:

区块高度/区块时间/第一个随机数/第二个随机数

69843020,1654499716,4970545721399468167,13510533599423613284

69843021,1654499717,9693390852741968751,2039449844162889432

69843022,1654499717,8214972371709431416,18405089478585935032

69843022,1654499717,8214972371709431416,18405089478585935032

基本也我们预期的差不多,不同区块内,unsafeRandom生成的随机数是不一样的。但一个区块内,函数的第一次调用,生成随机数是一样的,一个区块内的一个函数的多次调用,生成的随机数是不一样的。因为flow区块链的区块生成是1s左右,因此,如果是业务量不是那么大,好比一天mint几千个以内,使用unsafeRandom()生成随机数,问题不是很大。

当然,我们也可以参考以太坊常用的方法,把用户地址等业务数据加入到随机数生成中,这样就避免区块内的随机数碰撞问题。基于Cadence一个基本实现如下所示:

pub fun main(user_address:Address):UInt256{

  let rand_int = unsafeRandom()

  let rand_data: [UInt8] = rand_int.toBigEndianBytes()  // is `[73, 150, 2, 210, ...]`

  let tag = user_address.toString()

  let data = HashAlgorithm.KECCAK_256.hashWithTag(rand_data, tag:tag) //[UInt8]

  var data_int:UInt256 = 0

  var data_len = UInt256(data.length)

 //[UInt8] 转 UInt256

  for item in data {

    var ratio:UInt256 = 1

    var i:UInt256 = 0

    while (i<data_len-1) {

        ratio = ratio*256

        i = i + 1

    }

    data_int = data_int + UInt256(item)*ratio

    data_len = data_len - 1

  }

  return data_int

}

加上用户地址之后,区块内的随机数碰撞问题也基本解决了。如果业务场景涉及利益不是特别大的情况下,好比一般的盲盒、NFT游戏场景,使用这种“区块随机数据+业务数据”的方式生成随机数,完全可以满足需求。

我们具体再看看,得到的随机数分布是不是符合我们的预期,我们还是用加用户地址生成随机数的函数,使用不同的账号,生成10000个0-9的随机数。

生成的0-9随机数,统计结果为:(数字,数量)  [(0, 951), (1, 1005), (2, 1004), (3, 1047), (4, 987), (5, 1017), (6, 985), (7, 970), (8, 1038), (9, 996)]。数据统计分布如下图所示:

图5 生成0-9的随机数个数统计图

按照预期,每个数字应该是1000个左右,实际上生成的大致都是950个到1050个之间,数量误差不超过5%。还是基本符合预期的。

Playground: Flow Playground

Github:GitHub - maris205/flow-is-best: flow cadence learning code. from solidity to cadence

猜你喜欢

转载自blog.csdn.net/wangliang_f/article/details/125155881