fun get_attributes(reg: &mut FoCRegistry, fc: &Traits): vector<Attribute>
而 img_url 则通过上述生成的特征构建出对应的 base64 编码的 svg 图片。
/// Construct an image URL for the NFT.
fun img_url(reg:&mut FoCRegistry, fc: &Traits): Url {
url::new_unsafe_from_bytes(token_uri(reg, fc))
}
fun token_uri(reg: &mut FoCRegistry, foc: &Traits): vector<u8> {
let uri = b"data:image/svg+xml;base64,";
vec::append(&mut uri, base64::encode(&draw_svg(reg, foc)));
uri
}
至此,我们可以通过 create_foc 方法创建一个 FoxOrChicken NFT。
2.3 铸造 NFT
接下来我们看到铸造 NFT 过程,大致过程为:
判断总供给量是否满足条件;
如果在 SUI 代币购买阶段,则转移 SUI 代币,否则,需要支付 EGG 代币进行铸造,EGG 的铸造和销毁在之后的章节中介绍;
铸造 NFT 并根据50%概率判断是否被质押的狐狸盗走;
如果选择质押则将 NFT 转入质押,否则转入铸造者的账户中。
// 文件: fox.move
/// mint a fox or chicken
public entry fun mint(
global: &mut Global,
treasury_cap: &mut TreasuryCap<EGG>,
amount: u64,
stake: bool,
pay_sui: vector<Coin<SUI>>,
pay_egg: vector<Coin<EGG>>,
ctx: &mut TxContext,
) {
assert_enabled(global);
// 检查供应量是否超出总供应量
assert!(amount > 0 && amount <= config::max_single_mint(), EINVALID_MINTING);
let token_supply = token_helper::total_supply(&global.foc_registry);
assert!(token_supply + amount <= config::target_max_tokens(), EALL_MINTED);
let receiver_addr = sender(ctx);
// 处理 SUI 代币付款
if (token_supply < config::paid_tokens()) {
assert!(vec::length(&pay_sui) > 0, EINSUFFICIENT_SUI_BALANCE);
assert!(token_supply + amount <= config::paid_tokens(), EALL_MINTED);
let price = config::mint_price() * amount;
let (paid, remainder) = merge_and_split(pay_sui, price, ctx);
coin::put(&mut global.balance, paid);
transfer(remainder, sender(ctx));
} else {
// EGG 代币付款阶段返还 SUI 代币
if (vec::length(&pay_sui) > 0) {
transfer(merge(pay_sui, ctx), sender(ctx));
} else {
vec::destroy_empty(pay_sui);
};
};
let id = object::new(ctx);
let seed = hash(object::uid_to_bytes(&id));
let total_egg_cost: u64 = 0;
let tokens: vector<FoxOrChicken> = vec::empty<FoxOrChicken>();
let i = 0;
while (i < amount) {
let token_index = token_supply + i + 1;
// 判断是否被狐狸盗走
let recipient: address = select_recipient(&mut global.pack, receiver_addr, seed, token_index);
let token = token_helper::create_foc(&mut global.foc_registry, ctx);
if (!stake || recipient != receiver_addr) {
transfer(token, receiver_addr);
} else {
vec::push_back(&mut tokens, token);
};
// 计算 EGG 代币花费
total_egg_cost = total_egg_cost + mint_cost(token_index);
i = i + 1;
};
// 如果需要 EGG 代币花费,则转移并销毁 EGG 代币
if (total_egg_cost > 0) {
assert!(vec::length(&pay_egg) > 0, EINSUFFICIENT_EGG_BALANCE);
// burn EGG
let total_egg = merge(pay_egg, ctx);
assert!(coin::value(&total_egg) >= total_egg_cost, EINSUFFICIENT_EGG_BALANCE);
let paid = coin::split(&mut total_egg, total_egg_cost, ctx);
egg::burn(treasury_cap, paid);
transfer(total_egg, sender(ctx));
} else {
if (vec::length(&pay_egg) > 0) {
transfer(merge(pay_egg, ctx), sender(ctx));
} else {
vec::destroy_empty(pay_egg);
};
};
// 铸造的同时质押,则将 NFT 转入重要中
if (stake) {
barn::stake_many_to_barn_and_pack(
&mut global.barn_registry,
&mut global.barn,
&mut global.pack,
tokens,
ctx
);
} else {
vec::destroy_empty(tokens);
};
object::delete(id);
}
这篇文章将向你介绍 Sui Move 版本的类狼羊游戏的合约和前端编写过程。阅读前,建议先熟悉以下内容:
项目代码:
在线 Demo: https://fox-game-interface.vercel.app/
0x1 狼羊游戏的规则
狼羊游戏是以太坊上的 NFT 游戏,玩家通过购买NFT,然后将 NFT 质押来获取游戏代币 $WOOL,游戏代币 $WOOL 可用于之后的 NFT 铸造。有趣的是,狼羊游戏在这个过程中引入了随机性,让单纯的质押过程增加了不确定性,因而吸引了大量玩家参与到游戏中,狼羊游戏的可玩性也是建立在这个基础之上。具体的游戏规则为:
1.1 羊
你有90%的概率铸造一只羊,每只羊都有独特的特征。以下是他们可以采取的行动:
进入谷仓(Stake)
每天累积 10,000 羊毛 $WOOL
剪羊毛 $WOOL (Claim)
收到的羊毛80%累积在羊的身上,狼对剪下的羊毛收取20%的税,作为不攻击谷仓的回报。征税的 $WOOL 分配给目前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
离开谷仓(Unstake)
羊被从谷仓中移除,所有 $WOOL 都被剪掉了。只有当羊积累了2天价值的 $WOOL 时才能离开谷仓,离开谷仓时你所有累积的 $WOOL 有50%的几率被狼全部偷走。被盗 $WOOL 分配给当前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
使用 $WOOL 铸造一个新羊
铸造的 NFT 有10%的可能性实际上是狼!新的羊或狼有10%的几率被质押的狼偷走。每只狼的成功机会与他们的 Alpha 分数成正比。
1.2 狼
你有 10% 的机会铸造一只狼,每只狼都有独特的特征,包括 5~8 的 Alpha 值。Alpha值越高,狼从税收中赚取的 $WOOL 部分越高,偷一只新铸造的羊或狼的概率也越高。只有被质押的狼才能偷羊或赚取 $WOOL 税。
例子:狼A的 Alpha 为8,狼B的 Alpha 为6,并且他们都被质押。
本次项目实践,我们将在 Sui 区块链上通过 Move 智能合约语言来实现游戏铸造,质押和获取 NFT 过程,并使用新的游戏元素:狐狸,鸡和鸡蛋,其中狐狸对应狼,鸡对应羊,鸡蛋对应羊毛,其他过程不变,我们将这个游戏命名为狐狸游戏。
0x2 合约开发
我们首先进行智能合约的编写,大致分为以下几个部分:
2.1 NFT 结构
首先我们定义狐狸和鸡的 NFT 的结构,我们使用一个结构体
FoxOrChicken
来表示这个 NFT, 通过is_chicken
来进行区分:其中,
url
既可以是指向 NFT 图片的链接,也可以是 base64 编码的字符串,比如data:image/svg+xml;base64,PHN2Zy......
。link
是一个指向 NFT 的页面。2.2 创建 NFT 对象
整个创建 NFT 的逻辑大致就是根据随机种子生成对应属性索引,根据属性索引构建对应的属性列表和图片,从而创建 NFT。
创建 NFT 使用到
FoCRegistry
结构体,这个数据结构用于记录关于 NFT 的一些数据,比如foc_born
记录生产的 NFT 总数,foc_hash
用于在生产 NFT 时产生随机数,该随机数用于生成 NFT 的属性,foc_hash
可以看作是 NFT 的基因。具体的属性值记录如下:创建 NFT 方法
create_foc
如下:其中
genetate_traits
用于根据foc_hash
生成 NFT 的属性值,此处属性为对应属性值的索引,select_trait
根据 A.J. Walker's Alias 算法根据预先设置好的每一个属性的随机概率(rarities
)来快速生成对应的属性索引。详情可以参考文章 https://zhuanlan.zhihu.com/p/436785581 中 A.J. Walker's Alias 算法一节。而
get_attributes
则是根据属性索引值对应从trait_types
和trait_data
中将属性的真实值取出并构建成属性数组。而
img_url
则通过上述生成的特征构建出对应的 base64 编码的 svg 图片。至此,我们可以通过
create_foc
方法创建一个 FoxOrChicken NFT。2.3 铸造 NFT
接下来我们看到铸造 NFT 过程,大致过程为:
2.4 质押 NFT
质押 NFT 时,我们通过 NFT 的属性值
is_chicken
来将不同的NFT放置到不同的容器中。其中,狐狸放置在 Pack 中,鸡放置在 Barn 中。每一个 NFT 在放置的同时记录对应的 owner 地址和用于计算质押收益的时间戳。对于
Barn
,除了记录 NFT 对象ID
与Stake
之间对应关系的items
,还增加了一个dynamic_field
,用于记录 owner 地址所有质押的 NFT 的数组:dynamic_field: <address, vector<ID>>
。同理,
Pack
也用items
记录了质押的所有 NFT,用 Alpha 进行了分类存储,在ObjectTable<u8, ObjectTable<u64, Stake>>
的结构中,第一个u8
对应于 Alpha 值,第二个ObjectTable<u64, Stake>
则是用ObjectTable
实现了vector
的功能,u64
对应Stake
的索引,因此,item_size 这个属性记录了每个 Alpha 值对应ObjectTable
的大小。pack_indices
用于记录每个 NFT 所在数组中的索引,最后还有一个dynamic_field
记录了 owner 地址的所有质押的 NFT 的数组。以上关于 Barn 和 Pack 的设计目的在于:
FoxOrChicken
成为Stake
的一个属性时,在区块链上无法追踪,因此,只能通过Stake
的 Object ID 进行追踪,items 都是为了保证能直接通过 NFT 的 Object ID 来对应到 Stake;dynamic_field
可以方便查询。我们接下来看到如何质押一个 Chicken 的 NFT,方法调用层级为
stake_many_to_barn_and_pack -> stake_chicken_to_barn -> add_chicken_to_barn, record_staked
:同理,质押 Fox 进入 Pack 中的过程也是类似的,这里就不再赘述,方法调用层级为
stake_many_to_barn_and_pack ->
stake_fox_to_pack ->``add_fox_to_pack, record_staked
。2.5 提取 NFT
提取 Chicken NFT 时,方法调用层级为
claim_many_from_barn_and_pack -> claim_chicken_from_barn -> remove_chicken_from_barn, remove_staked
主要的过程为:
同理,从 Pack 中提取 Fox 中的过程也是类似的,这里就不再赘述。
2.6 创建 EGG 代币和收集 EGG 代币
EGG 代币创建过程使用了 one-time-witness 模式,具体可以参考:Move 高阶语法 | 共学课优秀笔记 中的 Witness 模式一节。
代币的铸造能力
treasury_cap: TreasuryCap<EGG>
保存为共享对象,但是mint
和burn
方法t通过friend
关键字限制了只能在fox
和barn
模块中调用,因此控制了代币的产生和销毁的权限。2.7 初始化方法和 entry 方法
fox
模块作为整个包的入口模块,将对所有模块进行初始化,并提供 entry 方法。我们在 fox 模块中设置了
Global
作为全局参数的结构体,用来保存不同模块需要用到的不同对象,一来方便我们看到系统需要处理的对象信息,二来减少了方法调用时需要传入的参数个数,通过Global对象将不同模块的对象进行分发,可以有效减少代码复杂度。除了之前介绍过的 mint 方法,我们还提供用于质押和提取 NFT 的 entry 方法:
2.8 时间戳问题
目前 Sui 区块链还没有完全实现区块时间,而目前提供的
tx_context::epoch()
的精度为24小时,无法满足游戏需求。因此在游戏中,我们通过手动设置时间戳来模拟时间增加,以确保游戏顺利进行。在初始化时,将设置时间的能力给到了一个预先生成的专门用于设置时间戳的地址
0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa
。之后,我们可以设置定时任务进行时间戳更新,通过调用设置时间的命令进行,详细结果可以查看 3.2 节合约命令行调用:
至此,我们介绍了合约部分的主要功能,详细的代码可以阅读项目仓库。
0x3 合约部署和调用
下面,我们首先将部署合约,并通过命令行进行方法的调用。
3.1 合约部署
通过以下命令可以编译和部署合约:
输出结果为:
可以通过交易哈希
5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH
在 sui explorer 中查看部署的合约信息:通过
sui client object <object_id>
可以查看创建的 object 的属性,可以知道:0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e
为代币 EGG 的 TreasuryCap 的 ObjectId0x1d525318e381f93dd2b2f043d2ed96400b4f16d9
为 EGG 的 CoinMetadata0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
为部署的地址0xe364474bd00b7544b9393f0a2b0af2dbea143fd3
为 TimeManagerCap0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f
为 Global 对象0xe572b53c8fa93602ae97baca3a94e231c2917af6
为 FoCManagerCap 对象这些对象将在之后的命令行调用和前端项目中使用到。其他省略的创建的对象为 Trait 对象,在之后不会使用到。
3.2 合约命令行调用
设置环境变量
设置时间戳
之后的每一步操作前都需要同步一次时间戳,保证数据正确。
铸造 NFT
使用以下命令进行铸造:
其中:
\"1\"
表示铸造的数量为 1;false
表示不质押,如果要铸造的同时进行质押,可以修改为true
;\[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\]
是用于支付 0.0099 SUI 铸造费用的 SUI 对象;\[\]
表示用于支付EGG
的对象。可以看到生成的对象中,
0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2
在地址0x659f89084673bf4a993cdea89a94dabf93a2ddb4
之下,查看属性可以看到对应的 type 为0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::token_helper::FoxOrChicken
,这个就是我们铸造得到的 NFT,相应的其他属性也可以查看到,命令输出结果可以查看此 gist。或者,我们可以通过
sui_getObjectsOwnedByAddress
RPC 接口可以查看地址所拥有的对象,比如对于地址0x659f89084673bf4a993cdea89a94dabf93a2ddb4
,可以查看所有对象,过滤即可找到创建的对象。质押 NFT
通过以下命令对前一步铸造的 NFT 进行质押:
获取收益和 提取 NFT
通过以下命令获取质押收益 EGG:
等 48 小时之后,将
false
变为true
,可以进行 Unstake,将质押的 NFT 提取出来。至此,命令行操作完成。
0x4 前端开发
4.1 scaffold-move 开发脚手架
这个项目基于 NonceGeek DAO 的 scaffold-move 开发脚手架,这个脚手架目前包含 Aptos 和 Sui 两个公链的前端开发实例,可以可以在这个基础上快速进行 Sui 的前端部分开发。
通过运行以下步骤可以设置开发环境:
4.2 项目页面结构
项目页面主要包括三部分,位于
src/pages
目录:index,game 和 whitepapers:我们之后的部分主要聚焦在 game 页面。game 页面功能主要包括三部分:
其中,质押和提取时进行的多选操作,可以通过设置选择变量进行过滤来实现:
4.3 连接钱包
我们使用 Suiet 钱包开发的
@suiet/wallet-kit
包连接 Sui 钱包,从包对应的 WalletContextState 可以看出,useWallet
包含了我们在构建 App 时会使用到的基本信息和功能,比如钱包信息,链信息,连接状态信息,以及发送交易,签名信息等。在
src/components/SuiConnect.tsx
中,我们可以很方便的设置钱包连接功能:之后,我们将需要使用的信息在
src/pages/game.tsx
中引入:其中,
signAndExecuteTransaction
方法用来签名并执行交易,支持moveCall
,transferSui
,transferObject
等交易。4.4 RPC 接口调用
我们使用官方提供的
@mysten/sui.js
库调用 Sui 的 RPC 接口,这个库支持了大部分 Sui JSON-RPC,同时,还提供了一些额外的方法方便开发,例如:selectCoinsWithBalanceGreaterThanOrEqual
:获取大于等于指定数量的coin对象ID数组selectCoinSetWithCombinedBalanceGreaterThanOrEqual
:获取总和大于等于指定数量的coin对象ID数组这两个方法在需要在 NFT 铸造时支付 SUI 或者其他代币时十分有用。我们在
game.tsx
中引入 JsonProvider 进行初始化:其他方法的介绍可以参考库的文档,这里不多赘述。
4.5 铸造 NFT 等 entry 方法
我们首先看到如何铸造 NFT:
其中
arguments
参数对应 mint 方法所需要的参数。同理,其他的 entry 方法的调用和签名也与 Mint 方法类似,分别为:
4.6 合约数据读取
对于 Sui 公链,除了调用合约,另一块难点是合约数据的读取。相对于 EVM 合约,Move的合约数据结构更复杂,更难读取。由于在 Sui 中,Object 对象被包装后可能无法进行追踪(详情可以参考官方 Object 教程系列),因此在之前的数据结构设计中,Pack 和 Barn 中存储的 NFT 需要使用能进行追踪的数据结构。因此,ObjectTable 被做为基本的键值存储结构区别于不可追踪的 Table 数据类型。相应地,可以使用
sui_getDynamicFieldObject
来读取其中的数据,例如,通过读取保存在 PackStaked 中的 NFT 对象质押列表,从而通过getObjectBatch
可以获取当前地址所有的质押的 NFT。其中,
packStakedObject
对象ID通过GLOBAL
对象 ID 获取得到。对于当前地址所拥有的未质押的NFT,需要通过读取全部对象ID后进行类型过滤才能得到:
最后,对于当前地址中包含的 EGG 代币的余额,可以通过
getCoinBalancesOwnedByAddress
获得所有余额对象并进行求和得到。总结
至此,我们完成了狐狸游戏合约和前端代码的介绍。我们实现的狐狸游戏虽然功能上只有铸造,质押和提取这几个主要的功能,但是涉及 NFT 创建以及 Sui Move 的诸多语法,整体项目具有一定的难度。
这篇文章希望对有兴趣于 Sui 上的 NFT 的操作的同学有所帮助,也希望大家提出宝贵的建议和意见。项目目前只完成了初步的逻辑功能,还需要继续补充测试和形式验证,欢迎有兴趣的同学提交 Pull Request。
参考文档