Uniswap v3 无常损失分析
Sep 17, 202228 min. readUniswap v3 无常损失分析目标对 Uniswap v3 无常损失的定量分析;如何使用策略让 Uniswap v3 LP 获得更大的收益。Uniswap 概览基于恒定乘积的自动化做市商(AMM),去中心化交易所。 v1 版本:2018年11月解决了什么问题:传统交易所 order book 买卖双方不活跃导致的长时间挂单,交易效率低下功能:ETH ←→ ERC20 token 兑换带来的问题:token1 与 token2 之间的兑换需要借助 ETHUSDT → ETH → USDCv2 版本:2020年5月新功能自由组合交易对:token1 ←→ token2token1-token2 交易池LPers 提供流动性并赚取费用价格预言机(时间加权平均价格,TWAP)、闪电贷、最优化交易路径等带来的问题资金利用率低:在 x*y=k 的情况下,做市的价格区间在 (0, +∞) 的分布,当用户交易时,交易的量相比我们的流动性来说是很小的假设 ETH/DAI 交易对的实时价格为 1500 DAI/ETH,交易对的流动性池中共有资金:4...
Tornado Cash 基本原理
假设地址 A 发送了 100 ETH 给地址 B,由于在区块链上所有的数据都是公开的,所以全世界都知道地址 A 和地址 B 进行了一次交易,如果地址A和地址 B 属于同一个用户 Alice,则大家知道Alice仍然拥有 100 ETH,如果地址B属于用户 Bob,则大家知道 Bob 现在有 100ETH 了。一个问题就是:如何在交易的过程中保持隐蔽呢,或者说隐藏发送用户与接收用户之前的练习?那就要用到 Tornado Cash。 用户将资金存入Tornado Cash,然后将资金提取到另一个地址中,在区块链上记录上,这两个地址之间的联系就大概率断开了。那 Tornado Cash 是如何做到的呢?存款(deposit)过程首先我们看一下存款过程。用户在存款时需要生产两个随机数 secret 和 nullifier,并计算这两个数的一个哈希 commitment = hash(secret, nullifier),然后用户将需要混币的金额(比如 1 ETH)和 commitment 发送给 TC 合约的 deposit 函数,TC合约将保存这两个数据,commitment之后会用于...
使用 Merkle 树做 NFT 白名单验证
使用 Merkle 树做 NFT 白名单验证Merkle 树现在普遍用来做线上数据验证。这篇文章主要解释和实现使用 Merkle 树做 NFT 白名单验证。 使用 Merkle 树做 NFT 白名单验证,简单来说就是将所有的白名单钱包地址做为 Merkle 树的叶节点生成一棵 Merkle 树,在部署的NFT 合约中只存储 Merkle 树的 root hash,这样避免了在合约中存储所有白名单地址带来的高额 gas 费用。在 mint 时,前端生成钱包地址的 Merkle proof,调用合约进行验证即可。 一次验证过程前端和合约运行过程如图:图片来自 [3]Merkle 树详情请参见:https://en.wikipedia.org/wiki/Merkle_tree图片来自 [1]比如,以水果单词作为叶节点,生成 Merkle 树的结构如下:图片来自 [2]合约实现我们简单实现 Merkle 验证的过程,此合约包含以下功能:设置 Merkle 根哈希: setSaleMerkleRoot验证 Merkle proof: isValidMerkleProofmint 并记录是否...
<100 subscribers
Uniswap v3 无常损失分析
Sep 17, 202228 min. readUniswap v3 无常损失分析目标对 Uniswap v3 无常损失的定量分析;如何使用策略让 Uniswap v3 LP 获得更大的收益。Uniswap 概览基于恒定乘积的自动化做市商(AMM),去中心化交易所。 v1 版本:2018年11月解决了什么问题:传统交易所 order book 买卖双方不活跃导致的长时间挂单,交易效率低下功能:ETH ←→ ERC20 token 兑换带来的问题:token1 与 token2 之间的兑换需要借助 ETHUSDT → ETH → USDCv2 版本:2020年5月新功能自由组合交易对:token1 ←→ token2token1-token2 交易池LPers 提供流动性并赚取费用价格预言机(时间加权平均价格,TWAP)、闪电贷、最优化交易路径等带来的问题资金利用率低:在 x*y=k 的情况下,做市的价格区间在 (0, +∞) 的分布,当用户交易时,交易的量相比我们的流动性来说是很小的假设 ETH/DAI 交易对的实时价格为 1500 DAI/ETH,交易对的流动性池中共有资金:4...
Tornado Cash 基本原理
假设地址 A 发送了 100 ETH 给地址 B,由于在区块链上所有的数据都是公开的,所以全世界都知道地址 A 和地址 B 进行了一次交易,如果地址A和地址 B 属于同一个用户 Alice,则大家知道Alice仍然拥有 100 ETH,如果地址B属于用户 Bob,则大家知道 Bob 现在有 100ETH 了。一个问题就是:如何在交易的过程中保持隐蔽呢,或者说隐藏发送用户与接收用户之前的练习?那就要用到 Tornado Cash。 用户将资金存入Tornado Cash,然后将资金提取到另一个地址中,在区块链上记录上,这两个地址之间的联系就大概率断开了。那 Tornado Cash 是如何做到的呢?存款(deposit)过程首先我们看一下存款过程。用户在存款时需要生产两个随机数 secret 和 nullifier,并计算这两个数的一个哈希 commitment = hash(secret, nullifier),然后用户将需要混币的金额(比如 1 ETH)和 commitment 发送给 TC 合约的 deposit 函数,TC合约将保存这两个数据,commitment之后会用于...
使用 Merkle 树做 NFT 白名单验证
使用 Merkle 树做 NFT 白名单验证Merkle 树现在普遍用来做线上数据验证。这篇文章主要解释和实现使用 Merkle 树做 NFT 白名单验证。 使用 Merkle 树做 NFT 白名单验证,简单来说就是将所有的白名单钱包地址做为 Merkle 树的叶节点生成一棵 Merkle 树,在部署的NFT 合约中只存储 Merkle 树的 root hash,这样避免了在合约中存储所有白名单地址带来的高额 gas 费用。在 mint 时,前端生成钱包地址的 Merkle proof,调用合约进行验证即可。 一次验证过程前端和合约运行过程如图:图片来自 [3]Merkle 树详情请参见:https://en.wikipedia.org/wiki/Merkle_tree图片来自 [1]比如,以水果单词作为叶节点,生成 Merkle 树的结构如下:图片来自 [2]合约实现我们简单实现 Merkle 验证的过程,此合约包含以下功能:设置 Merkle 根哈希: setSaleMerkleRoot验证 Merkle proof: isValidMerkleProofmint 并记录是否...
Share Dialog
Share Dialog
这篇文章将向你介绍 Sui Move 版本的类狼羊游戏的合约和前端编写过程。阅读前,建议先熟悉以下内容:
项目代码:
在线 Demo: https://fox-game-interface.vercel.app/

狼羊游戏是以太坊上的 NFT 游戏,玩家通过购买NFT,然后将 NFT 质押来获取游戏代币 $WOOL,游戏代币 $WOOL 可用于之后的 NFT 铸造。有趣的是,狼羊游戏在这个过程中引入了随机性,让单纯的质押过程增加了不确定性,因而吸引了大量玩家参与到游戏中,狼羊游戏的可玩性也是建立在这个基础之上。具体的游戏规则为:
你有90%的概率铸造一只羊,每只羊都有独特的特征。以下是他们可以采取的行动:
进入谷仓(Stake)
每天累积 10,000 羊毛 $WOOL
剪羊毛 $WOOL (Claim)
收到的羊毛80%累积在羊的身上,狼对剪下的羊毛收取20%的税,作为不攻击谷仓的回报。征税的 $WOOL 分配给目前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
离开谷仓(Unstake)
羊被从谷仓中移除,所有 $WOOL 都被剪掉了。只有当羊积累了2天价值的 $WOOL 时才能离开谷仓,离开谷仓时你所有累积的 $WOOL 有50%的几率被狼全部偷走。被盗 $WOOL 分配给当前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
使用 $WOOL 铸造一个新羊
铸造的 NFT 有10%的可能性实际上是狼!新的羊或狼有10%的几率被质押的狼偷走。每只狼的成功机会与他们的 Alpha 分数成正比。
你有 10% 的机会铸造一只狼,每只狼都有独特的特征,包括 5~8 的 Alpha 值。Alpha值越高,狼从税收中赚取的 $WOOL 部分越高,偷一只新铸造的羊或狼的概率也越高。只有被质押的狼才能偷羊或赚取 $WOOL 税。
例子:狼A的 Alpha 为8,狼B的 Alpha 为6,并且他们都被质押。
如果累计 70,000 羊毛作为税款,狼A将能够获得 40,000 羊毛,狼B将能够获得 30,000 羊毛;
如果新铸造的羊或狼被盗,狼A有57%概率获得,狼B有43%的概率获得。
本次项目实践,我们将在 Sui 区块链上通过 Move 智能合约语言来实现游戏铸造,质押和获取 NFT 过程,并使用新的游戏元素:狐狸,鸡和鸡蛋,其中狐狸对应狼,鸡对应羊,鸡蛋对应羊毛,其他过程不变,我们将这个游戏命名为狐狸游戏。
我们首先进行智能合约的编写,大致分为以下几个部分:
创建 NFT
铸造 NFT(Mint)
质押 NFT (Stake)
鸡蛋(EGG)代币和收集鸡蛋(Collect/Claim)
提取 NFT(Unstake)
首先我们定义狐狸和鸡的 NFT 的结构,我们使用一个结构体 FoxOrChicken 来表示这个 NFT, 通过 is_chicken 来进行区分:
struct Attribute has store, copy, drop {
name: vector<u8>,
value: vector<u8>,
}
struct FoxOrChicken has key, store {
id: UID,
index: u64,
is_chicken: bool,
alpha: u8,
url: Url,
link: Url,
item_count: u8,
attributes: vector<Attribute>,
}
其中, url 既可以是指向 NFT 图片的链接,也可以是 base64 编码的字符串,比如 data:image/svg+xml;base64,PHN2Zy......。link 是一个指向 NFT 的页面。
整个创建 NFT 的逻辑大致就是根据随机种子生成对应属性索引,根据属性索引构建对应的属性列表和图片,从而创建 NFT。
创建 NFT 使用到 FoCRegistry 结构体,这个数据结构用于记录关于 NFT 的一些数据,比如 foc_born 记录生产的 NFT 总数,foc_hash 用于在生产 NFT 时产生随机数,该随机数用于生成 NFT 的属性,foc_hash 可以看作是 NFT 的基因。具体的属性值记录如下:
struct FoCRegistry has key, store {
id: UID,
foc_born: u64,
foc_hash: vector<u8>,
rarities: vector<vector<u8>>,
aliases: vector<vector<u8>>,
types: Table<ID, bool>,
alphas: Table<ID, u8>,
trait_data: Table<u8, Table<u8, Trait>>,
trait_types: vector<vector<u8>>,
}
创建 NFT 方法 create_foc 如下:
public(friend) fun create_foc(
reg: &mut FoCRegistry, ctx: &mut TxContext
): FoxOrChicken {
let id = object::new(ctx);
reg.foc_born = reg.foc_born + 1;
vec::append(&mut reg.foc_hash, object::uid_to_bytes(&id));
reg.foc_hash = hash(reg.foc_hash);
let fc = generate_traits(reg);
let attributes = get_attributes(reg, &fc);
let alpha = *vec::borrow(&ALPHAS, (fc.alpha_index as u64));
table::add(&mut reg.types, object::uid_to_inner(&id), fc.is_chicken);
if (!fc.is_chicken) {
table::add(&mut reg.alphas, object::uid_to_inner(&id), alpha);
};
emit(FoCBorn {
id: object::uid_to_inner(&id),
index: reg.foc_born,
attributes: *&attributes,
created_by: tx_context::sender(ctx),
});
FoxOrChicken {
id,
index: reg.foc_born,
is_chicken: fc.is_chicken,
alpha: alpha,
url: img_url(reg, &fc),
link: link_url(reg.foc_born, fc.is_chicken),
attributes,
item_count: 0,
}
}
其中 genetate_traits 用于根据 foc_hash 生成 NFT 的属性值,此处属性为对应属性值的索引,select_trait 根据 A.J. Walker’s Alias 算法根据预先设置好的每一个属性的随机概率(rarities)来快速生成对应的属性索引。详情可以参考文章 https://zhuanlan.zhihu.com/p/436785581 中 A.J. Walker’s Alias 算法一节****。****
public fun generate_traits(
reg: &FoCRegistry,
): Traits {
let seed = reg.foc_hash;
let is_chicken = *vec::borrow(&seed, 0) >= 26;
let shift = if (is_chicken) 0 else 9;
Traits {
is_chicken,
fur: select_trait(reg, *vec::borrow(&seed, 1), *vec::borrow(&seed, 10), 0 + shift),
head: select_trait(reg, *vec::borrow(&seed, 2), *vec::borrow(&seed, 11), 1 + shift),
ears: select_trait(reg, *vec::borrow(&seed, 3), *vec::borrow(&seed, 12), 2 + shift),
eyes: select_trait(reg, *vec::borrow(&seed, 4), *vec::borrow(&seed, 13), 3 + shift),
nose: select_trait(reg, *vec::borrow(&seed, 5), *vec::borrow(&seed, 14), 4 + shift),
mouth: select_trait(reg, *vec::borrow(&seed, 6), *vec::borrow(&seed, 15), 5 + shift),
neck: select_trait(reg, *vec::borrow(&seed, 7), *vec::borrow(&seed, 16), 6 + shift),
feet: select_trait(reg, *vec::borrow(&seed, 8), *vec::borrow(&seed, 17), 7 + shift),
alpha_index: select_trait(reg, *vec::borrow(&seed, 9), *vec::borrow(&seed, 18), 8 + shift),
}
}
fun select_trait(reg: &FoCRegistry, seed1: u8, seed2: u8, trait_type: u64): u8 {
let trait = (seed1 as u64) % vec::length(vec::borrow(®.rarities, trait_type));
if (seed2 < *vec::borrow(vec::borrow(®.rarities, trait_type), trait)) {
return (trait as u8)
};
*vec::borrow(vec::borrow(®.aliases, trait_type), trait)
}
而 get_attributes 则是根据属性索引值对应从 trait_types 和 trait_data 中将属性的真实值取出并构建成属性数组。
fun get_attributes(reg: &mut FoCRegistry, fc: &Traits): vector<Attribute>
而 img_url 则通过上述生成的特征构建出对应的 base64 编码的 svg 图片。
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。
接下来我们看到铸造 NFT 过程,大致过程为:
判断总供给量是否满足条件;
如果在 SUI 代币购买阶段,则转移 SUI 代币,否则,需要支付 EGG 代币进行铸造,EGG 的铸造和销毁在之后的章节中介绍;
铸造 NFT 并根据50%概率判断是否被质押的狐狸盗走;
如果选择质押则将 NFT 转入质押,否则转入铸造者的账户中。
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);
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 {
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);
};
total_egg_cost = total_egg_cost + mint_cost(token_index);
i = i + 1;
};
if (total_egg_cost > 0) {
assert!(vec::length(&pay_egg) > 0, EINSUFFICIENT_EGG_BALANCE);
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);
};
};
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);
}
质押 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;
记录 owner 地址的所有质押的 NFT ID 的数组是为了方便在业务中查询某个地址的质押的 NFT,dynamic_field 可以方便查询。
struct Stake has key, store {
id: UID,
item: FoxOrChicken,
value: u64,
owner: address,
}
struct Barn has key, store {
id: UID,
items: ObjectTable<ID, Stake>,
}
struct Pack has key, store {
id: UID,
items: ObjectTable<u8, ObjectTable<u64, Stake>>,
item_size: vector<u64>,
pack_indices: Table<ID, u64>,
}
我们接下来看到如何质押一个 Chicken 的 NFT,方法调用层级为 stake_many_to_barn_and_pack -> stake_chicken_to_barn -> add_chicken_to_barn, record_staked :
public fun stake_many_to_barn_and_pack(
reg: &mut BarnRegistry,
barn: &mut Barn,
pack: &mut Pack,
tokens: vector<FoxOrChicken>,
ctx: &mut TxContext,
) {
let i = vec::length<FoxOrChicken>(&tokens);
while (i > 0) {
let token = vec::pop_back(&mut tokens);
if (token_helper::is_chicken(&token)) {
update_earnings(reg, ctx);
stake_chicken_to_barn(reg, barn, token, ctx);
} else {
stake_fox_to_pack(reg, pack, token, ctx);
};
i = i - 1;
};
vec::destroy_empty(tokens)
}
fun stake_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext) {
reg.total_chicken_staked = reg.total_chicken_staked + 1;
let stake_id = add_chicken_to_barn(reg, barn, item, ctx);
record_staked(&mut barn.id, sender(ctx), stake_id);
}
fun add_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext): ID {
let foc_id = object::id(&item);
let value = timestamp_now(reg, ctx);
let stake = Stake {
id: object::new(ctx),
item,
value,
owner: sender(ctx),
};
let stake_id = object::id(&stake);
emit(FoCStaked { id: foc_id, owner: sender(ctx), value });
object_table::add(&mut barn.items, foc_id, stake);
stake_id
}
fun record_staked(staked: &mut UID, account: address, stake_id: ID) {
if (dof::exists_(staked, account)) {
vec::push_back(dof::borrow_mut(staked, account), stake_id);
} else {
dof::add(staked, account, vec::singleton(stake_id));
};
}
同理,质押 Fox 进入 Pack 中的过程也是类似的,这里就不再赘述,方法调用层级为 stake_many_to_barn_and_pack -> stake_fox_to_pack ->``add_fox_to_pack, record_staked 。
提取 Chicken NFT 时,方法调用层级为 claim_many_from_barn_and_pack -> claim_chicken_from_barn -> remove_chicken_from_barn, remove_staked
主要的过程为:
判断 NFT 类型,根据类型从不同的容器中提取 NFT;
判断 NFT 是否存在,是否超过最小质押时间;
计算质押收益;
如果选择提取 NFT,则收益50%概率被狐狸全部拿走;
如果只收集鸡蛋,则需要交 20% 作为保护费。
public fun claim_many_from_barn_and_pack(
foc_reg: &mut FoCRegistry,
reg: &mut BarnRegistry,
barn: &mut Barn,
pack: &mut Pack,
treasury_cap: &mut TreasuryCap<EGG>,
tokens: vector<ID>,
unstake: bool,
ctx: &mut TxContext,
) {
update_earnings(reg, ctx);
let i = vec::length<ID>(&tokens);
let owed: u64 = 0;
while (i > 0) {
let token_id = vec::pop_back(&mut tokens);
if (token_helper::is_chicken_from_id(foc_reg, token_id)) {
owed = owed + claim_chicken_from_barn(reg, barn, token_id, unstake, ctx);
} else {
owed = owed + claim_fox_from_pack(foc_reg, reg, pack, token_id, unstake, ctx);
};
i = i - 1;
};
if (owed == 0) { return };
egg::mint(treasury_cap, owed, sender(ctx), ctx);
vec::destroy_empty(tokens)
}
fun claim_chicken_from_barn(
reg: &mut BarnRegistry,
barn: &mut Barn,
foc_id: ID,
unstake: bool,
ctx: &mut TxContext
): u64 {
assert!(object_table::contains(&barn.items, foc_id), ENOT_IN_PACK_OR_BARN);
let stake_time = get_chicken_stake_value(barn, foc_id);
let timenow = timestamp_now(reg, ctx);
assert!(!(unstake && timenow - stake_time < MINIMUM_TO_EXIT), ESTILL_COLD);
let owed: u64;
if (reg.total_egg_earned < MAXIMUM_GLOBAL_EGG) {
owed = (timenow - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
} else if (stake_time > reg.last_claim_timestamp) {
owed = 0;
} else {
owed = (reg.last_claim_timestamp - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
};
if (unstake) {
let id = object::new(ctx);
if (random::rand_u64_range_with_seed(hash(object::uid_to_bytes(&id)), 0, 2) == 0) {
pay_fox_tax(reg, owed);
owed = 0;
};
object::delete(id);
reg.total_chicken_staked = reg.total_chicken_staked - 1;
let (item, stake_id) = remove_chicken_from_barn(barn, foc_id, ctx);
remove_staked(&mut barn.id, sender(ctx), stake_id);
transfer::transfer(item, sender(ctx));
} else {
pay_fox_tax(reg, owed * EGG_CLAIM_TAX_PERCENTAGE / 100);
owed = owed * (100 - EGG_CLAIM_TAX_PERCENTAGE) / 100;
set_chicken_stake_value(barn, foc_id, timenow);
};
emit(FoCClaimed { id: foc_id, earned: owed, unstake });
owed
}
同理,从 Pack 中提取 Fox 中的过程也是类似的,这里就不再赘述。
EGG 代币创建过程使用了 one-time-witness 模式,具体可以参考:Move 高阶语法 | 共学课优秀笔记 中的 Witness 模式一节。
代币的铸造能力 treasury_cap: TreasuryCap<EGG> 保存为共享对象,但是 mint 和 burn 方法t通过 friend 关键字限制了只能在 fox 和 barn 模块中调用,因此控制了代币的产生和销毁的权限。
module fox_game::egg {
use std::option;
use sui::coin::{Self, Coin, TreasuryCap};
use sui::transfer;
use sui::tx_context::TxContext;
friend fox_game::fox;
friend fox_game::barn;
struct EGG has drop {}
fun init(witness: EGG, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency<EGG>(
witness,
9,
b"EGG",
b"Fox Game Egg",
b"Fox game egg coin",
option::none(),
ctx
);
transfer::freeze_object(metadata);
transfer::share_object(treasury_cap)
}
public(friend) fun mint(
treasury_cap: &mut TreasuryCap<EGG>, amount: u64, recipient: address, ctx: &mut TxContext
) {
coin::mint_and_transfer(treasury_cap, amount, recipient, ctx)
}
public(friend) fun burn(treasury_cap: &mut TreasuryCap<EGG>, coin: Coin<EGG>) {
coin::burn(treasury_cap, coin);
}
}
fox 模块作为整个包的入口模块,将对所有模块进行初始化,并提供 entry 方法。
我们在 fox 模块中设置了 Global 作为全局参数的结构体,用来保存不同模块需要用到的不同对象,一来方便我们看到系统需要处理的对象信息,二来减少了方法调用时需要传入的参数个数,通过Global对象将不同模块的对象进行分发,可以有效减少代码复杂度。
struct Global has key {
id: UID,
minting_enabled: bool,
balance: Balance<SUI>,
pack: Pack,
barn: Barn,
barn_registry: BarnRegistry,
foc_registry: FoCRegistry,
}
fun init(ctx: &mut TxContext) {
transfer(token_helper::init_foc_manage_cap(ctx), sender(ctx));
share_object(Global {
id: object::new(ctx),
minting_enabled: true,
balance: balance::zero(),
barn_registry: barn::init_barn_registry(ctx),
pack: barn::init_pack(ctx),
barn: barn::init_barn(ctx),
foc_registry: token_helper::init_foc_registry(ctx),
});
transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
}
除了之前介绍过的 mint 方法,我们还提供用于质押和提取 NFT 的 entry 方法:
public entry fun add_many_to_barn_and_pack(
global: &mut Global,
tokens: vector<FoxOrChicken>,
ctx: &mut TxContext,
) {
barn::stake_many_to_barn_and_pack(&mut global.barn_registry, &mut global.barn, &mut global.pack, tokens, ctx);
}
public entry fun claim_many_from_barn_and_pack(
global: &mut Global,
treasury_cap: &mut TreasuryCap<EGG>,
tokens: vector<ID>,
unstake: bool,
ctx: &mut TxContext,
) {
barn::claim_many_from_barn_and_pack(
&mut global.foc_registry,
&mut global.barn_registry,
&mut global.barn,
&mut global.pack,
treasury_cap,
tokens,
unstake,
ctx
);
}
目前 Sui 区块链还没有完全实现区块时间,而目前提供的 tx_context::epoch() 的精度为24小时,无法满足游戏需求。因此在游戏中,我们通过手动设置时间戳来模拟时间增加,以确保游戏顺利进行。
struct BarnRegistry has key, store {
id: UID,
timestamp: u64,
}
public(friend) fun set_timestamp(reg: &mut BarnRegistry, current: u64, _ctx: &mut TxContext) {
reg.timestamp = current;
}
fun timestamp_now(reg: &mut BarnRegistry, _ctx: &mut TxContext): u64 {
reg.timestamp
}
在初始化时,将设置时间的能力给到了一个预先生成的专门用于设置时间戳的地址 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa。
struct TimeManagerCap has key, store { id: UID }
public(friend) fun init_time_manager_cap(ctx: &mut TxContext): TimeManagerCap {
TimeManagerCap { id: object::new(ctx) }
}
fun init(ctx: &mut TxContext) {
transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
}
public entry fun set_timestamp(_: &TimeManagerCap, global: &mut Global, current: u64, ctx: &mut TxContext) {
barn::set_timestamp(&mut global.barn_registry, current, ctx)
}
之后,我们可以设置定时任务进行时间戳更新,通过调用设置时间的命令进行,详细结果可以查看 3.2 节合约命令行调用:
sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000
至此,我们介绍了合约部分的主要功能,详细的代码可以阅读项目仓库。
下面,我们首先将部署合约,并通过命令行进行方法的调用。
通过以下命令可以编译和部署合约:
sui move build
sui client publish . --gas-budget 300000
输出结果为:
$ sui client publish . --gas-budget 300000
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING fox_game
----- Certificate ----
Transaction Hash: 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH
Transaction Signature: AA==@G9yAoybgfIEi7Wj8HFYeEFwG5WPtJ4FlJ+/jaMXFPyjWg4pUun3WQpB4VH5gim/FzqspMY7QAJcd0iTyJ910Dw==@htyihgkhXVia7MCmWeGtDeU96b7w1ivXPKBAV37DZoo=
Signed Authorities Bitmap: RoaringBitmap<[0, 1, 2]>
Transaction Kind : Publish
Sender: 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a
Gas Payment: Object ID: 0x0942e72397f46a831ce61003601cbb05697e7a83, version: 0x20f, digest: 0xc318f23ac2772738efe1b958be0b51e3c49d9c772d5aede9f41e1dc69edeb2ea
Gas Price: 1
Gas Budget: 300000
----- Transaction Effects ----
Status : Success
Created Objects:
- 省略了其他的创建的对象
- ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
- ID: 0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 , Owner: Immutable
- ID: 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 , Owner: Immutable
- ID: 0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 , Owner: Account Address ( 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa )
- ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
- ID: 0xe572b53c8fa93602ae97baca3a94e231c2917af6 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )
Mutated Objects:
- ID: 0x0942e72397f46a831ce61003601cbb05697e7a83 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )
可以通过交易哈希 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH 在 sui explorer 中查看部署的合约信息:

通过 sui client object <object_id> 可以查看创建的 object 的属性,可以知道:
0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e 为代币 EGG 的 TreasuryCap 的 ObjectId
0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 为 EGG 的 CoinMetadata
0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 为部署的地址
0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 为 TimeManagerCap
0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f 为 Global 对象
0xe572b53c8fa93602ae97baca3a94e231c2917af6 为 FoCManagerCap 对象
这些对象将在之后的命令行调用和前端项目中使用到。其他省略的创建的对象为 Trait 对象,在之后不会使用到。
设置环境变量
export fox_game=0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
export global=0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f
export egg_treasury=0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e
export time_cap=0xe364474bd00b7544b9393f0a2b0af2dbea143fd3
设置时间戳
sui client switch --address 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa
sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000
curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "sui_getObject",
"params":[
"0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f"
]
}' | jq .result.details.data.fields.barn_registry
{
"type": "0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::barn::BarnRegistry",
"fields": {
"egg_per_alpha": "0",
"id": {
"id": "0x48136d916ea8a148ab864fdb1fc668f6e6dcf3ff"
},
"last_claim_timestamp": "0",
"timestamp": "1674831518",
"total_alpha_staked": "0",
"total_chicken_staked": "0",
"total_egg_earned": "0",
"unaccounted_rewards": "8518518"
}
}
铸造 NFT
使用以下命令进行铸造:
sui client switch --address 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
sui client gas
Object ID | Gas Value
----------------------------------------------------------------------
0x0bd32adfbfc73e8daa42eef21b4e4e6cc7081240 | 25219
0x2ad1e472502aefd87c3767157391ebc1f169c6b5 | 9928743
0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 | 10000000
0x5f2c80c89bedddf92f0dc32cfa16b0ecf76a4680 | 10000000
0x635ce8d2e9a9c0056ff1cd8591baee16fe010911 | 10000000
sui client call --function mint --module fox --package ${fox_game} --args ${global} ${egg_treasury} \"1\" false \[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\] \[\] --gas-budget 100000
----- Certificate ----
Transaction Hash: 7p1nmTPYE9884gBCJL6sah2t6Vzh9P59MUeFVURXaEFx
Transaction Signature: AA==@TNx7guUd7EjEg4s8jyOf+kTkuhVqmzrZWGKzcJNM3iHqcCRk0+pzITmFth8dYM6qKnYAvT3eeSkKNDUaQF2LAA==@oC1nequkpzyJfYuKx7DqIZFNUfF66e+6DEF1Urqo/EM=
Signed Authorities Bitmap: RoaringBitmap<[1, 2, 3]>
Transaction Kind : Call
Package ID : 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
Module : fox
Function : mint
Arguments : ["0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f", "0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e", [1,0,0,0,0,0,0,0], "", ["0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05"], []]
Type Arguments : []
Sender: 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
Gas Payment: Object ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5, version: 0x215, digest: 0x197c624ca59151af7cd968b985062fa3e0dbf21711777d7b4602215664233c5b
Gas Price: 1
Gas Budget: 100000
----- Transaction Effects ----
Status : Success
Created Objects:
- ID: 0x185aa8a244c74ddfe83c38618b46c744425cd7f5 , Owner: Object ID: ( 0x2ba674fcac290baa2927ff26110463f337237f0d )
- ID: 0x6917cbcf0e6e58184a98e05ad6bbc70a75755d28 , Owner: Object ID: ( 0x2ed343ceebf792a36b2ff0e918b801e34399f4ed )
- ID: 0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
Mutated Objects:
- ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
- ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
- ID: 0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
- ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
其中:
- `\"1\"` 表示铸造的数量为 1;
- `false` 表示不质押,如果要铸造的同时进行质押,可以修改为 `true`;
- `\[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\]` 是用于支付 0.0099 SUI 铸造费用的 SUI 对象;
- `\[\]` 表示用于支付 `EGG` 的对象。
可以看到生成的对象中, `0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2` 在地址 `0x659f89084673bf4a993cdea89a94dabf93a2ddb4` 之下,查看属性可以看到对应的 type 为 `0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::token_helper::FoxOrChicken` ,这个就是我们铸造得到的 NFT,相应的其他属性也可以查看到,命令输出结果可以查看此 [gist](https://gist.github.com/qiwihui/86e7385c635f88b539ed2f032018ca28)。
或者,我们可以通过 `sui_getObjectsOwnedByAddress` RPC 接口可以查看地址所拥有的对象,比如对于地址 `0x659f89084673bf4a993cdea89a94dabf93a2ddb4` ,可以查看所有对象,过滤即可找到创建的对象。
$ curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "sui_getObjectsOwnedByAddress",
"params":[
"0x659f89084673bf4a993cdea89a94dabf93a2ddb4"
]
}'
质押 NFT
通过以下命令对前一步铸造的 NFT 进行质押:
sui client call --function add_many_to_barn_and_pack --module fox --package ${fox_game} --args ${global} \[0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2\] --gas-budget 100000
获取收益和 提取 NFT
通过以下命令获取质押收益 EGG:
sui client call --function claim_many_from_barn_and_pack --module fox --package ${fox_game} --args ${global} ${egg_treasury} '["0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2"]' false --gas-budget 100000
等 48 小时之后,将 `false` 变为 `true`,可以进行 Unstake,将质押的 NFT 提取出来。
至此,命令行操作完成。
这个项目基于 NonceGeek DAO 的 scaffold-move 开发脚手架,这个脚手架目前包含 Aptos 和 Sui 两个公链的前端开发实例,可以可以在这个基础上快速进行 Sui 的前端部分开发。
通过运行以下步骤可以设置开发环境:
git clone https://github.com/NonceGeek/scaffold-move.git
cd scaffold-move/scaffold-sui/
yarn
yarn dev
项目页面主要包括三部分,位于 src/pages 目录:index,game 和 whitepapers:
index:入口页面,做为游戏的引导页面;
game:主要的逻辑页面,涉及铸造,质押和提取;
whitepaper:白皮书页面,介绍游戏机制和玩法。
我们之后的部分主要聚焦在 game 页面。game 页面功能主要包括三部分:
菜单栏:包含logo,页面导航以及链接钱包;
左侧 Mint 栏:主要当前 mint 状态和 mint 操作;
右侧 Stake 栏:主要是 Stake,Unstale 和 Collect EGG 的操作。

其中,质押和提取时进行的多选操作,可以通过设置选择变量进行过滤来实现:
const [unstakedSelected, setUnstakedSelected] = useState<Array<string>>([])
const [stakedSelected, setStakedSelected] = useState<Array<string>>([]);
// 设置添加和删除操作
function addStaked(item: string) {
setUnstakedSelected([])
setStakedSelected([...stakedSelected, item])
}
function removeStaked(item: string) {
setUnstakedSelected([])
setStakedSelected(stakedSelected.filter(i => i !== item))
}
function addUnstaked(item: string) {
setStakedSelected([])
setUnstakedSelected([...unstakedSelected, item])
}
function removeUnstaked(item: string) {
setStakedSelected([])
setUnstakedSelected(unstakedSelected.filter(i => i !== item))
}
// 之后添加元素的点击事件
// 处理未质押的
function renderUnstaked(item: any, type: string) {
const itemIn = unstakedSelected.includes(item.objectId);
return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
<div className="flex flex-col items-center">
<div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
<Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeUnstaked(item.objectId) : addUnstaked(item.objectId)} />
</div>
</div>
}
// 处理质押的
function renderStaked(item: any, type: string) {
const itemIn = stakedSelected.includes(item.objectId);
return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
<div className="flex flex-col items-center">
<div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
<Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeStaked(item.objectId) : addStaked(item.objectId)} />
</div>
</div>
}
我们使用 Suiet 钱包开发的 @suiet/wallet-kit 包连接 Sui 钱包,从包对应的 WalletContextState 可以看出, useWallet 包含了我们在构建 App 时会使用到的基本信息和功能,比如钱包信息,链信息,连接状态信息,以及发送交易,签名信息等。
export interface WalletContextState {
configuredWallets: IWallet[];
detectedWallets: IWallet[];
allAvailableWallets: IWallet[];
chains: Chain[];
chain: Chain | undefined;
name: string | undefined;
adapter: IWalletAdapter | undefined;
account: WalletAccount | undefined;
address: string | undefined;
connecting: boolean;
connected: boolean;
status: "disconnected" | "connected" | "connecting";
select: (walletName: string) => void;
disconnect: () => Promise<void>;
getAccounts: () => readonly WalletAccount[];
signAndExecuteTransaction(transaction: SuiSignAndExecuteTransactionInput): Promise<SuiSignAndExecuteTransactionOutput>;
signMessage: (input: {
message: Uint8Array;
}) => Promise<ExpSignMessageOutput>;
on: <E extends WalletEvent>(event: E, listener: WalletEventListeners[E]) => () => void;
}
export declare const WalletContext: import("react").Context<WalletContextState>;
export declare function useWallet(): WalletContextState;
在 src/components/SuiConnect.tsx 中,我们可以很方便的设置钱包连接功能:
import {
ConnectButton,
} from '@suiet/wallet-kit';
export function SuiConnect() {
return (
<ConnectButton>Connect Wallet</ConnectButton>
);
}
之后,我们将需要使用的信息在 src/pages/game.tsx 中引入:
import {
useWallet,
} from '@suiet/wallet-kit';
export default function Home() {
const { signAndExecuteTransaction, connected, account, status } = useWallet();
// 省略
其中, signAndExecuteTransaction 方法用来签名并执行交易,支持 moveCall , transferSui, transferObject 等交易。
我们使用官方提供的 @mysten/sui.js 库调用 Sui 的 RPC 接口,这个库支持了大部分 Sui JSON-RPC,同时,还提供了一些额外的方法方便开发,例如:
selectCoinsWithBalanceGreaterThanOrEqual :获取大于等于指定数量的coin对象ID数组
selectCoinSetWithCombinedBalanceGreaterThanOrEqual:获取总和大于等于指定数量的coin对象ID数组
这两个方法在需要在 NFT 铸造时支付 SUI 或者其他代币时十分有用。我们在 game.tsx 中引入 JsonProvider 进行初始化:
// 文件: src/pages/game.tsx
import { JsonRpcProvider } from '@mysten/sui.js';
export default function Home() {
// 操作 client
const provider = new JsonRpcProvider();
// 调用
const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
// 其他省略
其他方法的介绍可以参考库的文档,这里不多赘述。
我们首先看到如何铸造 NFT:
// 文件: src/pages/game.tsx
async function mint_nft() {
let suiObjectIds = [] as Array<string>
let eggObiectIds = [] as Array<string>
// 获取足够的 SUI 或者 EGG 代币的对象ID
if (collectionSupply < PAID_TOKENS) {
const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
suiObjectIds = suiObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
} else {
const eggObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, eggCost, `${PACKAGE_ID}::egg::EGG`)
eggObiectIds = eggObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
}
try {
// 调用 moveCall 方法,构造交易并签名
const resData = await signAndExecuteTransaction({
transaction: {
kind: 'moveCall',
data: mint(false, suiObjectIds, eggObiectIds),
},
});
// 检查结果
if (resData.effects.status.status !== "success") {
console.log('failed', resData);
}
// 设置 Mint 交易
setMintTx('https://explorer.sui.io/transaction/' + resData.certificate.transactionDigest)
} catch (e) {
console.error('failed', e);
}
}
// 构造 mint 方法所需要的参数
function mint(stake: boolean, sui_objects: Array<string>, egg_objects: Array<string>) {
return {
packageObjectId: PACKAGE_ID,
module: 'fox',
function: 'mint',
typeArguments: [],
arguments: [
GLOBAL, EGG_TREASUTY, mintAmount.toString(), stake, sui_objects, egg_objects
],
gasBudget: 1000000,
};
}
return (
// 其他部分省略
<div className="text-center font-console pt-1" onClick={() => mint_nft()}>Mint</div>
)
其中 arguments 参数对应 mint 方法所需要的参数。
同理,其他的 entry 方法的调用和签名也与 Mint 方法类似,分别为:
// 铸造并质押
async function mint_nft_stake()
// 质押
async function stake_nft()
// 提取
async function unstake_nft()
// 收集 EGG
async function claim_egg()
对于 Sui 公链,除了调用合约,另一块难点是合约数据的读取。相对于 EVM 合约,Move的合约数据结构更复杂,更难读取。由于在 Sui 中,Object 对象被包装后可能无法进行追踪(详情可以参考官方 Object 教程系列),因此在之前的数据结构设计中,Pack 和 Barn 中存储的 NFT 需要使用能进行追踪的数据结构。因此,ObjectTable 被做为基本的键值存储结构区别于不可追踪的 Table 数据类型。相应地,可以使用 sui_getDynamicFieldObject 来读取其中的数据,例如,通过读取保存在 PackStaked 中的 NFT 对象质押列表,从而通过 getObjectBatch 可以获取当前地址所有的质押的 NFT。
// 读取 Pack 中质押的 Fox NFT
const objects: any = await sui_client.getDynamicFieldObject(packStakedObject, account!.address);
if (objects != null) {
const fox_staked = objects.details.data.fields.value
const fox_stakes = await provider.getObjectBatch(fox_staked)
const staked = fox_stakes.filter(item => item.status === "Exists").map((item: any) => {
let foc = item.details.data.fields.item
return {
objectId: foc.fields.id.id,
index: parseInt(foc.fields.index),
url: foc.fields.url,
}
})
setStakedFox(staked)
}
}
其中, packStakedObject 对象ID通过 GLOBAL 对象 ID 获取得到。
const globalObject: any = await provider.getObject(GLOBAL)
const pack_staked = globalObject.details.data.fields.pack.fields.id.id
setPackStakedObject(pack_staked)
对于当前地址所拥有的未质押的NFT,需要通过读取全部对象ID后进行类型过滤才能得到:
// 获取所有对象
const objects = await provider.getObjectsOwnedByAddress(account!.address)
// 过滤 FoxOrChicken 对象
const foc = objects
.filter(item => item.type === `${PACKAGE_ID}::token_helper::FoxOrChicken`)
.map(item => item.objectId)
const foces = await provider.getObjectBatch(foc)
// 过滤并读取信息,然后排序
const unstaked = foces.filter(item => item.status === "Exists").map((item: any) => {
return {
objectId: item.details.data.fields.id.id,
index: parseInt(item.details.data.fields.index),
url: item.details.data.fields.url,
is_chicken: item.details.data.fields.is_chicken,
}
}).sort((n1, n2) => n1.index - n2.index)
// 存储
setUnstakedFox(unstaked.filter(item => !item.is_chicken))
setUnstakedChicken(unstaked.filter(item => item.is_chicken))
最后,对于当前地址中包含的 EGG 代币的余额,可以通过 getCoinBalancesOwnedByAddress 获得所有余额对象并进行求和得到。
const balanceObjects = await provider.getCoinBalancesOwnedByAddress(account!.address, `${PACKAGE_ID}::egg::EGG`)
const balances = balanceObjects.filter(item => item.status === 'Exists').map((item: any) => parseInt(item.details.data.fields.balance))
const initialValue = 0;
const sumWithInitial = balances.reduce(
(accumulator, currentValue) => accumulator + currentValue,
initialValue
)
setEggBalance(sumWithInitial);
至此,我们完成了狐狸游戏合约和前端代码的介绍。我们实现的狐狸游戏虽然功能上只有铸造,质押和提取这几个主要的功能,但是涉及 NFT 创建以及 Sui Move 的诸多语法,整体项目具有一定的难度。
这篇文章希望对有兴趣于 Sui 上的 NFT 的操作的同学有所帮助,也希望大家提出宝贵的建议和意见。项目目前只完成了初步的逻辑功能,还需要继续补充测试和形式验证,欢迎有兴趣的同学提交 Pull Request。
这篇文章将向你介绍 Sui Move 版本的类狼羊游戏的合约和前端编写过程。阅读前,建议先熟悉以下内容:
项目代码:
在线 Demo: https://fox-game-interface.vercel.app/

狼羊游戏是以太坊上的 NFT 游戏,玩家通过购买NFT,然后将 NFT 质押来获取游戏代币 $WOOL,游戏代币 $WOOL 可用于之后的 NFT 铸造。有趣的是,狼羊游戏在这个过程中引入了随机性,让单纯的质押过程增加了不确定性,因而吸引了大量玩家参与到游戏中,狼羊游戏的可玩性也是建立在这个基础之上。具体的游戏规则为:
你有90%的概率铸造一只羊,每只羊都有独特的特征。以下是他们可以采取的行动:
进入谷仓(Stake)
每天累积 10,000 羊毛 $WOOL
剪羊毛 $WOOL (Claim)
收到的羊毛80%累积在羊的身上,狼对剪下的羊毛收取20%的税,作为不攻击谷仓的回报。征税的 $WOOL 分配给目前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
离开谷仓(Unstake)
羊被从谷仓中移除,所有 $WOOL 都被剪掉了。只有当羊积累了2天价值的 $WOOL 时才能离开谷仓,离开谷仓时你所有累积的 $WOOL 有50%的几率被狼全部偷走。被盗 $WOOL 分配给当前在谷仓中质押的所有狼,数量与他们的 Alpha 分数成正比。
使用 $WOOL 铸造一个新羊
铸造的 NFT 有10%的可能性实际上是狼!新的羊或狼有10%的几率被质押的狼偷走。每只狼的成功机会与他们的 Alpha 分数成正比。
你有 10% 的机会铸造一只狼,每只狼都有独特的特征,包括 5~8 的 Alpha 值。Alpha值越高,狼从税收中赚取的 $WOOL 部分越高,偷一只新铸造的羊或狼的概率也越高。只有被质押的狼才能偷羊或赚取 $WOOL 税。
例子:狼A的 Alpha 为8,狼B的 Alpha 为6,并且他们都被质押。
如果累计 70,000 羊毛作为税款,狼A将能够获得 40,000 羊毛,狼B将能够获得 30,000 羊毛;
如果新铸造的羊或狼被盗,狼A有57%概率获得,狼B有43%的概率获得。
本次项目实践,我们将在 Sui 区块链上通过 Move 智能合约语言来实现游戏铸造,质押和获取 NFT 过程,并使用新的游戏元素:狐狸,鸡和鸡蛋,其中狐狸对应狼,鸡对应羊,鸡蛋对应羊毛,其他过程不变,我们将这个游戏命名为狐狸游戏。
我们首先进行智能合约的编写,大致分为以下几个部分:
创建 NFT
铸造 NFT(Mint)
质押 NFT (Stake)
鸡蛋(EGG)代币和收集鸡蛋(Collect/Claim)
提取 NFT(Unstake)
首先我们定义狐狸和鸡的 NFT 的结构,我们使用一个结构体 FoxOrChicken 来表示这个 NFT, 通过 is_chicken 来进行区分:
struct Attribute has store, copy, drop {
name: vector<u8>,
value: vector<u8>,
}
struct FoxOrChicken has key, store {
id: UID,
index: u64,
is_chicken: bool,
alpha: u8,
url: Url,
link: Url,
item_count: u8,
attributes: vector<Attribute>,
}
其中, url 既可以是指向 NFT 图片的链接,也可以是 base64 编码的字符串,比如 data:image/svg+xml;base64,PHN2Zy......。link 是一个指向 NFT 的页面。
整个创建 NFT 的逻辑大致就是根据随机种子生成对应属性索引,根据属性索引构建对应的属性列表和图片,从而创建 NFT。
创建 NFT 使用到 FoCRegistry 结构体,这个数据结构用于记录关于 NFT 的一些数据,比如 foc_born 记录生产的 NFT 总数,foc_hash 用于在生产 NFT 时产生随机数,该随机数用于生成 NFT 的属性,foc_hash 可以看作是 NFT 的基因。具体的属性值记录如下:
struct FoCRegistry has key, store {
id: UID,
foc_born: u64,
foc_hash: vector<u8>,
rarities: vector<vector<u8>>,
aliases: vector<vector<u8>>,
types: Table<ID, bool>,
alphas: Table<ID, u8>,
trait_data: Table<u8, Table<u8, Trait>>,
trait_types: vector<vector<u8>>,
}
创建 NFT 方法 create_foc 如下:
public(friend) fun create_foc(
reg: &mut FoCRegistry, ctx: &mut TxContext
): FoxOrChicken {
let id = object::new(ctx);
reg.foc_born = reg.foc_born + 1;
vec::append(&mut reg.foc_hash, object::uid_to_bytes(&id));
reg.foc_hash = hash(reg.foc_hash);
let fc = generate_traits(reg);
let attributes = get_attributes(reg, &fc);
let alpha = *vec::borrow(&ALPHAS, (fc.alpha_index as u64));
table::add(&mut reg.types, object::uid_to_inner(&id), fc.is_chicken);
if (!fc.is_chicken) {
table::add(&mut reg.alphas, object::uid_to_inner(&id), alpha);
};
emit(FoCBorn {
id: object::uid_to_inner(&id),
index: reg.foc_born,
attributes: *&attributes,
created_by: tx_context::sender(ctx),
});
FoxOrChicken {
id,
index: reg.foc_born,
is_chicken: fc.is_chicken,
alpha: alpha,
url: img_url(reg, &fc),
link: link_url(reg.foc_born, fc.is_chicken),
attributes,
item_count: 0,
}
}
其中 genetate_traits 用于根据 foc_hash 生成 NFT 的属性值,此处属性为对应属性值的索引,select_trait 根据 A.J. Walker’s Alias 算法根据预先设置好的每一个属性的随机概率(rarities)来快速生成对应的属性索引。详情可以参考文章 https://zhuanlan.zhihu.com/p/436785581 中 A.J. Walker’s Alias 算法一节****。****
public fun generate_traits(
reg: &FoCRegistry,
): Traits {
let seed = reg.foc_hash;
let is_chicken = *vec::borrow(&seed, 0) >= 26;
let shift = if (is_chicken) 0 else 9;
Traits {
is_chicken,
fur: select_trait(reg, *vec::borrow(&seed, 1), *vec::borrow(&seed, 10), 0 + shift),
head: select_trait(reg, *vec::borrow(&seed, 2), *vec::borrow(&seed, 11), 1 + shift),
ears: select_trait(reg, *vec::borrow(&seed, 3), *vec::borrow(&seed, 12), 2 + shift),
eyes: select_trait(reg, *vec::borrow(&seed, 4), *vec::borrow(&seed, 13), 3 + shift),
nose: select_trait(reg, *vec::borrow(&seed, 5), *vec::borrow(&seed, 14), 4 + shift),
mouth: select_trait(reg, *vec::borrow(&seed, 6), *vec::borrow(&seed, 15), 5 + shift),
neck: select_trait(reg, *vec::borrow(&seed, 7), *vec::borrow(&seed, 16), 6 + shift),
feet: select_trait(reg, *vec::borrow(&seed, 8), *vec::borrow(&seed, 17), 7 + shift),
alpha_index: select_trait(reg, *vec::borrow(&seed, 9), *vec::borrow(&seed, 18), 8 + shift),
}
}
fun select_trait(reg: &FoCRegistry, seed1: u8, seed2: u8, trait_type: u64): u8 {
let trait = (seed1 as u64) % vec::length(vec::borrow(®.rarities, trait_type));
if (seed2 < *vec::borrow(vec::borrow(®.rarities, trait_type), trait)) {
return (trait as u8)
};
*vec::borrow(vec::borrow(®.aliases, trait_type), trait)
}
而 get_attributes 则是根据属性索引值对应从 trait_types 和 trait_data 中将属性的真实值取出并构建成属性数组。
fun get_attributes(reg: &mut FoCRegistry, fc: &Traits): vector<Attribute>
而 img_url 则通过上述生成的特征构建出对应的 base64 编码的 svg 图片。
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。
接下来我们看到铸造 NFT 过程,大致过程为:
判断总供给量是否满足条件;
如果在 SUI 代币购买阶段,则转移 SUI 代币,否则,需要支付 EGG 代币进行铸造,EGG 的铸造和销毁在之后的章节中介绍;
铸造 NFT 并根据50%概率判断是否被质押的狐狸盗走;
如果选择质押则将 NFT 转入质押,否则转入铸造者的账户中。
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);
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 {
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);
};
total_egg_cost = total_egg_cost + mint_cost(token_index);
i = i + 1;
};
if (total_egg_cost > 0) {
assert!(vec::length(&pay_egg) > 0, EINSUFFICIENT_EGG_BALANCE);
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);
};
};
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);
}
质押 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;
记录 owner 地址的所有质押的 NFT ID 的数组是为了方便在业务中查询某个地址的质押的 NFT,dynamic_field 可以方便查询。
struct Stake has key, store {
id: UID,
item: FoxOrChicken,
value: u64,
owner: address,
}
struct Barn has key, store {
id: UID,
items: ObjectTable<ID, Stake>,
}
struct Pack has key, store {
id: UID,
items: ObjectTable<u8, ObjectTable<u64, Stake>>,
item_size: vector<u64>,
pack_indices: Table<ID, u64>,
}
我们接下来看到如何质押一个 Chicken 的 NFT,方法调用层级为 stake_many_to_barn_and_pack -> stake_chicken_to_barn -> add_chicken_to_barn, record_staked :
public fun stake_many_to_barn_and_pack(
reg: &mut BarnRegistry,
barn: &mut Barn,
pack: &mut Pack,
tokens: vector<FoxOrChicken>,
ctx: &mut TxContext,
) {
let i = vec::length<FoxOrChicken>(&tokens);
while (i > 0) {
let token = vec::pop_back(&mut tokens);
if (token_helper::is_chicken(&token)) {
update_earnings(reg, ctx);
stake_chicken_to_barn(reg, barn, token, ctx);
} else {
stake_fox_to_pack(reg, pack, token, ctx);
};
i = i - 1;
};
vec::destroy_empty(tokens)
}
fun stake_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext) {
reg.total_chicken_staked = reg.total_chicken_staked + 1;
let stake_id = add_chicken_to_barn(reg, barn, item, ctx);
record_staked(&mut barn.id, sender(ctx), stake_id);
}
fun add_chicken_to_barn(reg: &mut BarnRegistry, barn: &mut Barn, item: FoxOrChicken, ctx: &mut TxContext): ID {
let foc_id = object::id(&item);
let value = timestamp_now(reg, ctx);
let stake = Stake {
id: object::new(ctx),
item,
value,
owner: sender(ctx),
};
let stake_id = object::id(&stake);
emit(FoCStaked { id: foc_id, owner: sender(ctx), value });
object_table::add(&mut barn.items, foc_id, stake);
stake_id
}
fun record_staked(staked: &mut UID, account: address, stake_id: ID) {
if (dof::exists_(staked, account)) {
vec::push_back(dof::borrow_mut(staked, account), stake_id);
} else {
dof::add(staked, account, vec::singleton(stake_id));
};
}
同理,质押 Fox 进入 Pack 中的过程也是类似的,这里就不再赘述,方法调用层级为 stake_many_to_barn_and_pack -> stake_fox_to_pack ->``add_fox_to_pack, record_staked 。
提取 Chicken NFT 时,方法调用层级为 claim_many_from_barn_and_pack -> claim_chicken_from_barn -> remove_chicken_from_barn, remove_staked
主要的过程为:
判断 NFT 类型,根据类型从不同的容器中提取 NFT;
判断 NFT 是否存在,是否超过最小质押时间;
计算质押收益;
如果选择提取 NFT,则收益50%概率被狐狸全部拿走;
如果只收集鸡蛋,则需要交 20% 作为保护费。
public fun claim_many_from_barn_and_pack(
foc_reg: &mut FoCRegistry,
reg: &mut BarnRegistry,
barn: &mut Barn,
pack: &mut Pack,
treasury_cap: &mut TreasuryCap<EGG>,
tokens: vector<ID>,
unstake: bool,
ctx: &mut TxContext,
) {
update_earnings(reg, ctx);
let i = vec::length<ID>(&tokens);
let owed: u64 = 0;
while (i > 0) {
let token_id = vec::pop_back(&mut tokens);
if (token_helper::is_chicken_from_id(foc_reg, token_id)) {
owed = owed + claim_chicken_from_barn(reg, barn, token_id, unstake, ctx);
} else {
owed = owed + claim_fox_from_pack(foc_reg, reg, pack, token_id, unstake, ctx);
};
i = i - 1;
};
if (owed == 0) { return };
egg::mint(treasury_cap, owed, sender(ctx), ctx);
vec::destroy_empty(tokens)
}
fun claim_chicken_from_barn(
reg: &mut BarnRegistry,
barn: &mut Barn,
foc_id: ID,
unstake: bool,
ctx: &mut TxContext
): u64 {
assert!(object_table::contains(&barn.items, foc_id), ENOT_IN_PACK_OR_BARN);
let stake_time = get_chicken_stake_value(barn, foc_id);
let timenow = timestamp_now(reg, ctx);
assert!(!(unstake && timenow - stake_time < MINIMUM_TO_EXIT), ESTILL_COLD);
let owed: u64;
if (reg.total_egg_earned < MAXIMUM_GLOBAL_EGG) {
owed = (timenow - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
} else if (stake_time > reg.last_claim_timestamp) {
owed = 0;
} else {
owed = (reg.last_claim_timestamp - stake_time) * DAILY_EGG_RATE / ONE_DAY_IN_SECOND;
};
if (unstake) {
let id = object::new(ctx);
if (random::rand_u64_range_with_seed(hash(object::uid_to_bytes(&id)), 0, 2) == 0) {
pay_fox_tax(reg, owed);
owed = 0;
};
object::delete(id);
reg.total_chicken_staked = reg.total_chicken_staked - 1;
let (item, stake_id) = remove_chicken_from_barn(barn, foc_id, ctx);
remove_staked(&mut barn.id, sender(ctx), stake_id);
transfer::transfer(item, sender(ctx));
} else {
pay_fox_tax(reg, owed * EGG_CLAIM_TAX_PERCENTAGE / 100);
owed = owed * (100 - EGG_CLAIM_TAX_PERCENTAGE) / 100;
set_chicken_stake_value(barn, foc_id, timenow);
};
emit(FoCClaimed { id: foc_id, earned: owed, unstake });
owed
}
同理,从 Pack 中提取 Fox 中的过程也是类似的,这里就不再赘述。
EGG 代币创建过程使用了 one-time-witness 模式,具体可以参考:Move 高阶语法 | 共学课优秀笔记 中的 Witness 模式一节。
代币的铸造能力 treasury_cap: TreasuryCap<EGG> 保存为共享对象,但是 mint 和 burn 方法t通过 friend 关键字限制了只能在 fox 和 barn 模块中调用,因此控制了代币的产生和销毁的权限。
module fox_game::egg {
use std::option;
use sui::coin::{Self, Coin, TreasuryCap};
use sui::transfer;
use sui::tx_context::TxContext;
friend fox_game::fox;
friend fox_game::barn;
struct EGG has drop {}
fun init(witness: EGG, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency<EGG>(
witness,
9,
b"EGG",
b"Fox Game Egg",
b"Fox game egg coin",
option::none(),
ctx
);
transfer::freeze_object(metadata);
transfer::share_object(treasury_cap)
}
public(friend) fun mint(
treasury_cap: &mut TreasuryCap<EGG>, amount: u64, recipient: address, ctx: &mut TxContext
) {
coin::mint_and_transfer(treasury_cap, amount, recipient, ctx)
}
public(friend) fun burn(treasury_cap: &mut TreasuryCap<EGG>, coin: Coin<EGG>) {
coin::burn(treasury_cap, coin);
}
}
fox 模块作为整个包的入口模块,将对所有模块进行初始化,并提供 entry 方法。
我们在 fox 模块中设置了 Global 作为全局参数的结构体,用来保存不同模块需要用到的不同对象,一来方便我们看到系统需要处理的对象信息,二来减少了方法调用时需要传入的参数个数,通过Global对象将不同模块的对象进行分发,可以有效减少代码复杂度。
struct Global has key {
id: UID,
minting_enabled: bool,
balance: Balance<SUI>,
pack: Pack,
barn: Barn,
barn_registry: BarnRegistry,
foc_registry: FoCRegistry,
}
fun init(ctx: &mut TxContext) {
transfer(token_helper::init_foc_manage_cap(ctx), sender(ctx));
share_object(Global {
id: object::new(ctx),
minting_enabled: true,
balance: balance::zero(),
barn_registry: barn::init_barn_registry(ctx),
pack: barn::init_pack(ctx),
barn: barn::init_barn(ctx),
foc_registry: token_helper::init_foc_registry(ctx),
});
transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
}
除了之前介绍过的 mint 方法,我们还提供用于质押和提取 NFT 的 entry 方法:
public entry fun add_many_to_barn_and_pack(
global: &mut Global,
tokens: vector<FoxOrChicken>,
ctx: &mut TxContext,
) {
barn::stake_many_to_barn_and_pack(&mut global.barn_registry, &mut global.barn, &mut global.pack, tokens, ctx);
}
public entry fun claim_many_from_barn_and_pack(
global: &mut Global,
treasury_cap: &mut TreasuryCap<EGG>,
tokens: vector<ID>,
unstake: bool,
ctx: &mut TxContext,
) {
barn::claim_many_from_barn_and_pack(
&mut global.foc_registry,
&mut global.barn_registry,
&mut global.barn,
&mut global.pack,
treasury_cap,
tokens,
unstake,
ctx
);
}
目前 Sui 区块链还没有完全实现区块时间,而目前提供的 tx_context::epoch() 的精度为24小时,无法满足游戏需求。因此在游戏中,我们通过手动设置时间戳来模拟时间增加,以确保游戏顺利进行。
struct BarnRegistry has key, store {
id: UID,
timestamp: u64,
}
public(friend) fun set_timestamp(reg: &mut BarnRegistry, current: u64, _ctx: &mut TxContext) {
reg.timestamp = current;
}
fun timestamp_now(reg: &mut BarnRegistry, _ctx: &mut TxContext): u64 {
reg.timestamp
}
在初始化时,将设置时间的能力给到了一个预先生成的专门用于设置时间戳的地址 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa。
struct TimeManagerCap has key, store { id: UID }
public(friend) fun init_time_manager_cap(ctx: &mut TxContext): TimeManagerCap {
TimeManagerCap { id: object::new(ctx) }
}
fun init(ctx: &mut TxContext) {
transfer(config::init_time_manager_cap(ctx), @0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa);
}
public entry fun set_timestamp(_: &TimeManagerCap, global: &mut Global, current: u64, ctx: &mut TxContext) {
barn::set_timestamp(&mut global.barn_registry, current, ctx)
}
之后,我们可以设置定时任务进行时间戳更新,通过调用设置时间的命令进行,详细结果可以查看 3.2 节合约命令行调用:
sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000
至此,我们介绍了合约部分的主要功能,详细的代码可以阅读项目仓库。
下面,我们首先将部署合约,并通过命令行进行方法的调用。
通过以下命令可以编译和部署合约:
sui move build
sui client publish . --gas-budget 300000
输出结果为:
$ sui client publish . --gas-budget 300000
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING fox_game
----- Certificate ----
Transaction Hash: 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH
Transaction Signature: AA==@G9yAoybgfIEi7Wj8HFYeEFwG5WPtJ4FlJ+/jaMXFPyjWg4pUun3WQpB4VH5gim/FzqspMY7QAJcd0iTyJ910Dw==@htyihgkhXVia7MCmWeGtDeU96b7w1ivXPKBAV37DZoo=
Signed Authorities Bitmap: RoaringBitmap<[0, 1, 2]>
Transaction Kind : Publish
Sender: 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a
Gas Payment: Object ID: 0x0942e72397f46a831ce61003601cbb05697e7a83, version: 0x20f, digest: 0xc318f23ac2772738efe1b958be0b51e3c49d9c772d5aede9f41e1dc69edeb2ea
Gas Price: 1
Gas Budget: 300000
----- Transaction Effects ----
Status : Success
Created Objects:
- 省略了其他的创建的对象
- ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
- ID: 0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 , Owner: Immutable
- ID: 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 , Owner: Immutable
- ID: 0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 , Owner: Account Address ( 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa )
- ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
- ID: 0xe572b53c8fa93602ae97baca3a94e231c2917af6 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )
Mutated Objects:
- ID: 0x0942e72397f46a831ce61003601cbb05697e7a83 , Owner: Account Address ( 0xefbb0d3f2dc566f1f4fa844621bee76b43c9579a )
可以通过交易哈希 5FZi4YxiiBJsCj67JSSzkVZvHdJjKKPtMMMrfGbmPXvH 在 sui explorer 中查看部署的合约信息:

通过 sui client object <object_id> 可以查看创建的 object 的属性,可以知道:
0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e 为代币 EGG 的 TreasuryCap 的 ObjectId
0x1d525318e381f93dd2b2f043d2ed96400b4f16d9 为 EGG 的 CoinMetadata
0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885 为部署的地址
0xe364474bd00b7544b9393f0a2b0af2dbea143fd3 为 TimeManagerCap
0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f 为 Global 对象
0xe572b53c8fa93602ae97baca3a94e231c2917af6 为 FoCManagerCap 对象
这些对象将在之后的命令行调用和前端项目中使用到。其他省略的创建的对象为 Trait 对象,在之后不会使用到。
设置环境变量
export fox_game=0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
export global=0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f
export egg_treasury=0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e
export time_cap=0xe364474bd00b7544b9393f0a2b0af2dbea143fd3
设置时间戳
sui client switch --address 0xa3e46ec682bb5082849c240d2f2d20b0f6e054aa
sui client call --function set_timestamp --module fox --package ${fox_game} --args ${time_cap} ${global} \"$(date +%s)\" --gas-budget 30000
curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "sui_getObject",
"params":[
"0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f"
]
}' | jq .result.details.data.fields.barn_registry
{
"type": "0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::barn::BarnRegistry",
"fields": {
"egg_per_alpha": "0",
"id": {
"id": "0x48136d916ea8a148ab864fdb1fc668f6e6dcf3ff"
},
"last_claim_timestamp": "0",
"timestamp": "1674831518",
"total_alpha_staked": "0",
"total_chicken_staked": "0",
"total_egg_earned": "0",
"unaccounted_rewards": "8518518"
}
}
铸造 NFT
使用以下命令进行铸造:
sui client switch --address 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
sui client gas
Object ID | Gas Value
----------------------------------------------------------------------
0x0bd32adfbfc73e8daa42eef21b4e4e6cc7081240 | 25219
0x2ad1e472502aefd87c3767157391ebc1f169c6b5 | 9928743
0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 | 10000000
0x5f2c80c89bedddf92f0dc32cfa16b0ecf76a4680 | 10000000
0x635ce8d2e9a9c0056ff1cd8591baee16fe010911 | 10000000
sui client call --function mint --module fox --package ${fox_game} --args ${global} ${egg_treasury} \"1\" false \[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\] \[\] --gas-budget 100000
----- Certificate ----
Transaction Hash: 7p1nmTPYE9884gBCJL6sah2t6Vzh9P59MUeFVURXaEFx
Transaction Signature: AA==@TNx7guUd7EjEg4s8jyOf+kTkuhVqmzrZWGKzcJNM3iHqcCRk0+pzITmFth8dYM6qKnYAvT3eeSkKNDUaQF2LAA==@oC1nequkpzyJfYuKx7DqIZFNUfF66e+6DEF1Urqo/EM=
Signed Authorities Bitmap: RoaringBitmap<[1, 2, 3]>
Transaction Kind : Call
Package ID : 0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885
Module : fox
Function : mint
Arguments : ["0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f", "0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e", [1,0,0,0,0,0,0,0], "", ["0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05"], []]
Type Arguments : []
Sender: 0x659f89084673bf4a993cdea89a94dabf93a2ddb4
Gas Payment: Object ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5, version: 0x215, digest: 0x197c624ca59151af7cd968b985062fa3e0dbf21711777d7b4602215664233c5b
Gas Price: 1
Gas Budget: 100000
----- Transaction Effects ----
Status : Success
Created Objects:
- ID: 0x185aa8a244c74ddfe83c38618b46c744425cd7f5 , Owner: Object ID: ( 0x2ba674fcac290baa2927ff26110463f337237f0d )
- ID: 0x6917cbcf0e6e58184a98e05ad6bbc70a75755d28 , Owner: Object ID: ( 0x2ed343ceebf792a36b2ff0e918b801e34399f4ed )
- ID: 0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
Mutated Objects:
- ID: 0x17db4feb4652b8b5ce9ebf6dc7d29463b08e234e , Owner: Shared
- ID: 0x2ad1e472502aefd87c3767157391ebc1f169c6b5 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
- ID: 0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05 , Owner: Account Address ( 0x659f89084673bf4a993cdea89a94dabf93a2ddb4 )
- ID: 0xe4ffefc480e20129ff7893d7fd550b17fda0ab0f , Owner: Shared
其中:
- `\"1\"` 表示铸造的数量为 1;
- `false` 表示不质押,如果要铸造的同时进行质押,可以修改为 `true`;
- `\[0x3cd2bb1e03326e5141203cc008e6d2eb44a0df05\]` 是用于支付 0.0099 SUI 铸造费用的 SUI 对象;
- `\[\]` 表示用于支付 `EGG` 的对象。
可以看到生成的对象中, `0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2` 在地址 `0x659f89084673bf4a993cdea89a94dabf93a2ddb4` 之下,查看属性可以看到对应的 type 为 `0x59a85fbef4bc17cd73f8ff89d227fdcd6226c885::token_helper::FoxOrChicken` ,这个就是我们铸造得到的 NFT,相应的其他属性也可以查看到,命令输出结果可以查看此 [gist](https://gist.github.com/qiwihui/86e7385c635f88b539ed2f032018ca28)。
或者,我们可以通过 `sui_getObjectsOwnedByAddress` RPC 接口可以查看地址所拥有的对象,比如对于地址 `0x659f89084673bf4a993cdea89a94dabf93a2ddb4` ,可以查看所有对象,过滤即可找到创建的对象。
$ curl https://fullnode.devnet.sui.io:443 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "sui_getObjectsOwnedByAddress",
"params":[
"0x659f89084673bf4a993cdea89a94dabf93a2ddb4"
]
}'
质押 NFT
通过以下命令对前一步铸造的 NFT 进行质押:
sui client call --function add_many_to_barn_and_pack --module fox --package ${fox_game} --args ${global} \[0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2\] --gas-budget 100000
获取收益和 提取 NFT
通过以下命令获取质押收益 EGG:
sui client call --function claim_many_from_barn_and_pack --module fox --package ${fox_game} --args ${global} ${egg_treasury} '["0x84fe8e597bcb9387b2911b5ef39b90bb111e71a2"]' false --gas-budget 100000
等 48 小时之后,将 `false` 变为 `true`,可以进行 Unstake,将质押的 NFT 提取出来。
至此,命令行操作完成。
这个项目基于 NonceGeek DAO 的 scaffold-move 开发脚手架,这个脚手架目前包含 Aptos 和 Sui 两个公链的前端开发实例,可以可以在这个基础上快速进行 Sui 的前端部分开发。
通过运行以下步骤可以设置开发环境:
git clone https://github.com/NonceGeek/scaffold-move.git
cd scaffold-move/scaffold-sui/
yarn
yarn dev
项目页面主要包括三部分,位于 src/pages 目录:index,game 和 whitepapers:
index:入口页面,做为游戏的引导页面;
game:主要的逻辑页面,涉及铸造,质押和提取;
whitepaper:白皮书页面,介绍游戏机制和玩法。
我们之后的部分主要聚焦在 game 页面。game 页面功能主要包括三部分:
菜单栏:包含logo,页面导航以及链接钱包;
左侧 Mint 栏:主要当前 mint 状态和 mint 操作;
右侧 Stake 栏:主要是 Stake,Unstale 和 Collect EGG 的操作。

其中,质押和提取时进行的多选操作,可以通过设置选择变量进行过滤来实现:
const [unstakedSelected, setUnstakedSelected] = useState<Array<string>>([])
const [stakedSelected, setStakedSelected] = useState<Array<string>>([]);
// 设置添加和删除操作
function addStaked(item: string) {
setUnstakedSelected([])
setStakedSelected([...stakedSelected, item])
}
function removeStaked(item: string) {
setUnstakedSelected([])
setStakedSelected(stakedSelected.filter(i => i !== item))
}
function addUnstaked(item: string) {
setStakedSelected([])
setUnstakedSelected([...unstakedSelected, item])
}
function removeUnstaked(item: string) {
setStakedSelected([])
setUnstakedSelected(unstakedSelected.filter(i => i !== item))
}
// 之后添加元素的点击事件
// 处理未质押的
function renderUnstaked(item: any, type: string) {
const itemIn = unstakedSelected.includes(item.objectId);
return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
<div className="flex flex-col items-center">
<div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
<Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeUnstaked(item.objectId) : addUnstaked(item.objectId)} />
</div>
</div>
}
// 处理质押的
function renderStaked(item: any, type: string) {
const itemIn = stakedSelected.includes(item.objectId);
return <div key={item.objectId} style={{ marginRight: "5px", marginLeft: "5px", border: itemIn ? "2px solid red" : "2px solid rgb(0,0,0,0)", overflow: 'hidden', display: "inline-block" }}>
<div className="flex flex-col items-center">
<div style={{ fontSize: "0.75rem", height: "1rem" }}>#{item.index}</div>
<Image src={`${item.url}`} width={48} height={48} alt={`${item.objectId}`} onClick={() => itemIn ? removeStaked(item.objectId) : addStaked(item.objectId)} />
</div>
</div>
}
我们使用 Suiet 钱包开发的 @suiet/wallet-kit 包连接 Sui 钱包,从包对应的 WalletContextState 可以看出, useWallet 包含了我们在构建 App 时会使用到的基本信息和功能,比如钱包信息,链信息,连接状态信息,以及发送交易,签名信息等。
export interface WalletContextState {
configuredWallets: IWallet[];
detectedWallets: IWallet[];
allAvailableWallets: IWallet[];
chains: Chain[];
chain: Chain | undefined;
name: string | undefined;
adapter: IWalletAdapter | undefined;
account: WalletAccount | undefined;
address: string | undefined;
connecting: boolean;
connected: boolean;
status: "disconnected" | "connected" | "connecting";
select: (walletName: string) => void;
disconnect: () => Promise<void>;
getAccounts: () => readonly WalletAccount[];
signAndExecuteTransaction(transaction: SuiSignAndExecuteTransactionInput): Promise<SuiSignAndExecuteTransactionOutput>;
signMessage: (input: {
message: Uint8Array;
}) => Promise<ExpSignMessageOutput>;
on: <E extends WalletEvent>(event: E, listener: WalletEventListeners[E]) => () => void;
}
export declare const WalletContext: import("react").Context<WalletContextState>;
export declare function useWallet(): WalletContextState;
在 src/components/SuiConnect.tsx 中,我们可以很方便的设置钱包连接功能:
import {
ConnectButton,
} from '@suiet/wallet-kit';
export function SuiConnect() {
return (
<ConnectButton>Connect Wallet</ConnectButton>
);
}
之后,我们将需要使用的信息在 src/pages/game.tsx 中引入:
import {
useWallet,
} from '@suiet/wallet-kit';
export default function Home() {
const { signAndExecuteTransaction, connected, account, status } = useWallet();
// 省略
其中, signAndExecuteTransaction 方法用来签名并执行交易,支持 moveCall , transferSui, transferObject 等交易。
我们使用官方提供的 @mysten/sui.js 库调用 Sui 的 RPC 接口,这个库支持了大部分 Sui JSON-RPC,同时,还提供了一些额外的方法方便开发,例如:
selectCoinsWithBalanceGreaterThanOrEqual :获取大于等于指定数量的coin对象ID数组
selectCoinSetWithCombinedBalanceGreaterThanOrEqual:获取总和大于等于指定数量的coin对象ID数组
这两个方法在需要在 NFT 铸造时支付 SUI 或者其他代币时十分有用。我们在 game.tsx 中引入 JsonProvider 进行初始化:
// 文件: src/pages/game.tsx
import { JsonRpcProvider } from '@mysten/sui.js';
export default function Home() {
// 操作 client
const provider = new JsonRpcProvider();
// 调用
const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
// 其他省略
其他方法的介绍可以参考库的文档,这里不多赘述。
我们首先看到如何铸造 NFT:
// 文件: src/pages/game.tsx
async function mint_nft() {
let suiObjectIds = [] as Array<string>
let eggObiectIds = [] as Array<string>
// 获取足够的 SUI 或者 EGG 代币的对象ID
if (collectionSupply < PAID_TOKENS) {
const suiObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, suiCost)
suiObjectIds = suiObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
} else {
const eggObjects = await provider.selectCoinSetWithCombinedBalanceGreaterThanOrEqual(account!.address, eggCost, `${PACKAGE_ID}::egg::EGG`)
eggObiectIds = eggObjects.filter(item => item.status === "Exists").map((item: any) => item.details.data.fields.id.id)
}
try {
// 调用 moveCall 方法,构造交易并签名
const resData = await signAndExecuteTransaction({
transaction: {
kind: 'moveCall',
data: mint(false, suiObjectIds, eggObiectIds),
},
});
// 检查结果
if (resData.effects.status.status !== "success") {
console.log('failed', resData);
}
// 设置 Mint 交易
setMintTx('https://explorer.sui.io/transaction/' + resData.certificate.transactionDigest)
} catch (e) {
console.error('failed', e);
}
}
// 构造 mint 方法所需要的参数
function mint(stake: boolean, sui_objects: Array<string>, egg_objects: Array<string>) {
return {
packageObjectId: PACKAGE_ID,
module: 'fox',
function: 'mint',
typeArguments: [],
arguments: [
GLOBAL, EGG_TREASUTY, mintAmount.toString(), stake, sui_objects, egg_objects
],
gasBudget: 1000000,
};
}
return (
// 其他部分省略
<div className="text-center font-console pt-1" onClick={() => mint_nft()}>Mint</div>
)
其中 arguments 参数对应 mint 方法所需要的参数。
同理,其他的 entry 方法的调用和签名也与 Mint 方法类似,分别为:
// 铸造并质押
async function mint_nft_stake()
// 质押
async function stake_nft()
// 提取
async function unstake_nft()
// 收集 EGG
async function claim_egg()
对于 Sui 公链,除了调用合约,另一块难点是合约数据的读取。相对于 EVM 合约,Move的合约数据结构更复杂,更难读取。由于在 Sui 中,Object 对象被包装后可能无法进行追踪(详情可以参考官方 Object 教程系列),因此在之前的数据结构设计中,Pack 和 Barn 中存储的 NFT 需要使用能进行追踪的数据结构。因此,ObjectTable 被做为基本的键值存储结构区别于不可追踪的 Table 数据类型。相应地,可以使用 sui_getDynamicFieldObject 来读取其中的数据,例如,通过读取保存在 PackStaked 中的 NFT 对象质押列表,从而通过 getObjectBatch 可以获取当前地址所有的质押的 NFT。
// 读取 Pack 中质押的 Fox NFT
const objects: any = await sui_client.getDynamicFieldObject(packStakedObject, account!.address);
if (objects != null) {
const fox_staked = objects.details.data.fields.value
const fox_stakes = await provider.getObjectBatch(fox_staked)
const staked = fox_stakes.filter(item => item.status === "Exists").map((item: any) => {
let foc = item.details.data.fields.item
return {
objectId: foc.fields.id.id,
index: parseInt(foc.fields.index),
url: foc.fields.url,
}
})
setStakedFox(staked)
}
}
其中, packStakedObject 对象ID通过 GLOBAL 对象 ID 获取得到。
const globalObject: any = await provider.getObject(GLOBAL)
const pack_staked = globalObject.details.data.fields.pack.fields.id.id
setPackStakedObject(pack_staked)
对于当前地址所拥有的未质押的NFT,需要通过读取全部对象ID后进行类型过滤才能得到:
// 获取所有对象
const objects = await provider.getObjectsOwnedByAddress(account!.address)
// 过滤 FoxOrChicken 对象
const foc = objects
.filter(item => item.type === `${PACKAGE_ID}::token_helper::FoxOrChicken`)
.map(item => item.objectId)
const foces = await provider.getObjectBatch(foc)
// 过滤并读取信息,然后排序
const unstaked = foces.filter(item => item.status === "Exists").map((item: any) => {
return {
objectId: item.details.data.fields.id.id,
index: parseInt(item.details.data.fields.index),
url: item.details.data.fields.url,
is_chicken: item.details.data.fields.is_chicken,
}
}).sort((n1, n2) => n1.index - n2.index)
// 存储
setUnstakedFox(unstaked.filter(item => !item.is_chicken))
setUnstakedChicken(unstaked.filter(item => item.is_chicken))
最后,对于当前地址中包含的 EGG 代币的余额,可以通过 getCoinBalancesOwnedByAddress 获得所有余额对象并进行求和得到。
const balanceObjects = await provider.getCoinBalancesOwnedByAddress(account!.address, `${PACKAGE_ID}::egg::EGG`)
const balances = balanceObjects.filter(item => item.status === 'Exists').map((item: any) => parseInt(item.details.data.fields.balance))
const initialValue = 0;
const sumWithInitial = balances.reduce(
(accumulator, currentValue) => accumulator + currentValue,
initialValue
)
setEggBalance(sumWithInitial);
至此,我们完成了狐狸游戏合约和前端代码的介绍。我们实现的狐狸游戏虽然功能上只有铸造,质押和提取这几个主要的功能,但是涉及 NFT 创建以及 Sui Move 的诸多语法,整体项目具有一定的难度。
这篇文章希望对有兴趣于 Sui 上的 NFT 的操作的同学有所帮助,也希望大家提出宝贵的建议和意见。项目目前只完成了初步的逻辑功能,还需要继续补充测试和形式验证,欢迎有兴趣的同学提交 Pull Request。
No comments yet