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行代码,就是删除已经抽取的数组元素。
合约地址:
类似的使用形式还有很多。像图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