抛硬币
本指南演示了在Move中编写模块 (智能合约),在Devnet上部署它,并添加一个TypeScript前端以与该模块进行通信。
Satoshi Coin Flip是一个dApp,利用可验证随机函数 (VRF) 在 Sui 区块链上创建一个公平的硬币游戏。用户(人类)与庄家(模块)进行游戏,并在正反面之间下注。然后,根据游戏的结果,用户要么获得其下注金额的两倍,要么什么都没有。
本指南假定你已经安装了Sui并了解Sui的基础知识。
后端
与所有Sui dApp一样,Satoshi Coin Flip的逻辑由链上的Move包提供支持。以下说明将带你完成创建和发布该模块的步骤。
House 模块
这个示例使用了多个模块来创建Satoshi Coin Flip游戏的包。第一个模块是house_data.move
。你需要在某个地方存储游戏数据,在这个模块中,你为所有房屋数据创建了一个共享对象。
Move模块的完整源代码,包括注释和其密码学概述,可以在Satoshi Coin Flip存储库中找到。
在开始之前,你必须初始化一个Move包。在你想要存储示例的目录中打开终端或控制台,并运行以下命令创建一个名为satoshi_flip
的空包:
sui move new satoshi_flip
完成了这一步,现在是时候进入一些代码了。在sources
目录中创建一个名为house_data.move
的新文件,并使用以下代码填充该文件:
module satoshi_flip::house_data {
use sui::object::{Self, UID};
use sui::balance::{Self, Balance};
use sui::sui::SUI;
use sui::coin::{Self, Coin};
use sui::package::{Self};
use sui::tx_context::{Self, TxContext};
use sui::transfer::{Self};
const ECallerNotHouse: u64 = 0;
const EInsufficientBalance: u64 = 1;
friend satoshi_flip::single_player_satoshi;
friend satoshi_flip::mev_attack_resistant_single_player_satoshi;
在这段代码中,有一些细节需要注意:
- 第一行将模块命名为
house_data
,属于satoshi_flip
包。 - 七行以
use
关键字开头,允许此模块使用在其他模块中声明的类型和函数(在本例中,它们都来自 Sui 标准库)。 - 两个错误代码。这些代码用于断言和单元测试,以确保程序按预期运行。
- 两个
friend
,或受信任的,模块。
接下来,在此模块中添加一些代码:
struct HouseData has key {
id: UID,
balance: Balance<SUI>,
house: address,
public_key: vector<u8>,
max_stake: u64,
min_stake: u64,
fees: Balance<SUI>,
base_fee_in_bp: u16
}
struct HouseCap has key {
id: UID
}
struct HOUSE_DATA has drop {}
fun init(otw: HOUSE_DATA, ctx: &mut TxContext) {
package::claim_and_keep(otw, ctx);
let house_cap = HouseCap {
id: object::new(ctx)
};
transfer::transfer(house_cap, tx_context::sender(ctx));
}
- 第一个结构体,
HouseData
,存储与游戏相关的最基本信息。 - 第二个结构体,
HouseCap
,是一个能力(capability),用于初始化房屋数据。 - 第三个结构体,
HOUSE_DATA
,是一个一次性证人(one-time witness),确保只存在一个HouseData
实例。 init
函数创建并发送Publisher
和HouseCap
对象给发送者。
到目前为止,你已经在模块中设置了数据结构。现在,创建一个初始化房屋数据并分享 HouseData
对象的函数:
public fun initialize_house_data(house_cap: HouseCap, coin: Coin<SUI>, public_key: vector<u8>, ctx: &mut TxContext) {
assert!(coin::value(&coin) > 0, EInsufficientBalance);
let house_data = HouseData {
id: object::new(ctx),
balance: coin::into_balance(coin),
house: tx_context::sender(ctx),
public_key,
max_stake: 50_000_000_000, // 50 SUI.
min_stake: 1_000_000_000, // 1 SUI.
fees: balance::zero(),
base_fee_in_bp: 100 // 1% in basis points.
};
let HouseCap { id } = house_cap;
object::delete(id);
transfer::share_object(house_data);
}
房屋数据已初始化,你还需要添加一些函数,以使房屋能够执行一些重要的行政任务:
public fun top_up(house_data: &mut HouseData, coin: Coin<SUI>, _: &mut TxContext) {
coin::put(&mut house_data.balance, coin)
}
public fun withdraw(house_data: &mut HouseData, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);
let total_balance = balance(house_data);
let coin = coin::take(&mut house_data.balance, total_balance, ctx);
transfer::public_transfer(coin, house(house_data));
}
public fun claim_fees(house_data: &mut HouseData, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);
let total_fees = fees(house_data);
let coin = coin::take(&mut house_data.fees, total_fees, ctx);
transfer::public_transfer(coin, house(house_data));
}
public fun update_max_stake(house_data: &mut HouseData, max_stake: u64, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);
house_data.max_stake = max_stake;
}
public fun update_min_stake(house_data: &mut HouseData, min_stake: u64, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);
house_data.min_stake = min_stake;
}
所有这些函数都包含一个 assert!
调用,确保只有房屋才能调用它们:
top_up
:增加房屋的余额,确保将来有足够的 SUI 用于游戏。withdraw
:提取房屋对象的整个余额。claim_fees
:提取房屋对象的累积费用。update_max_stake
、update_min_stake
:分别更新游戏中允许的最大和最小投注额。
你已经建立了该模块的数据结构,但没有适当的函数,这些数据是无法访问的。现在添加一些辅助函数,返回可变引用、只读引用和仅供测试的函数:
// --------------- Mutable References ---------------
public(friend) fun borrow_balance_mut(house_data: &mut HouseData): &mut Balance<SUI> {
&mut house_data.balance
}
public(friend) fun borrow_fees_mut(house_data: &mut HouseData): &mut Balance<SUI> {
&mut house_data.fees
}
public(friend) fun borrow_mut(house_data: &mut HouseData): &mut UID {
&mut house_data.id
}
// --------------- Read-only References ---------------
public(friend) fun borrow(house_data: &HouseData): &UID {
&house_data.id
}
public fun balance(house_data: &HouseData): u64 {
balance::value(&house_data.balance)
}
public fun house(house_data: &HouseData): address {
house_data.house
}
public fun public_key(house_data: &HouseData): vector<u8> {
house_data.public_key
}
public fun max_stake(house_data: &HouseData): u64 {
house_data.max_stake
}
public fun min_stake(house_data: &HouseData): u64 {
house_data.min_stake
}
public fun fees(house_data: &HouseData): u64 {
balance::value(&house_data.fees)
}
public fun base_fee_in_bp(house_data: &HouseData): u16 {
house_data.base_fee_in_bp
}
// --------------- Test-only Functions ---------------
#[test_only]
public fun init_for_testing(ctx: &mut TxContext) {
init(HOUSE_DATA {}, ctx);
}
}
至此,你的 house_data.move
代码已经完成。
计数器模块
在同一 sources
目录下,现在创建一个名为 counter_nft.move
的文件。Counter
对象用作玩家玩每个游戏的 VRF 输入。首先,用以下内容填充文件:
module satoshi_flip::counter_nft {
use std::vector;
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer::{Self};
use sui::bcs::{Self};
struct Counter has key {
id: UID,
count: u64,
}
entry fun burn(self: Counter) {
let Counter { id, count: _ } = self;
object::delete(id);
}
public fun mint(ctx: &mut TxContext): Counter {
Counter {
id: object::new(ctx),
count: 0
}
}
public fun transfer_to_sender(counter: Counter, ctx: &mut TxContext) {
transfer::transfer(counter, tx_context::sender(ctx));
}
这看起来可能与房屋模块相似。你设置了模块名称,从标准库导入函数,并初始化了 Counter
对象。Counter
对象具有 key
能力,但没有 store
- 这可以防止对象被转移。
此外,你创建了在设置游戏时使用的 mint
和 transfer_to_sender
函数,用于创建带有初始计数为 0
的 Counter
对象并将对象转移到交易发送者。最后,创建一个 burn
函数以允许删除 Counter
。
现在,你有了一个 Counter
对象,以及初始化和销毁该对象的函数,但你需要一种递增计数器的方法。向模块添加以下代码:
public fun get_vrf_input_and_increment(self: &mut Counter): vector<u8> {
let vrf_input = object::id_bytes(self);
let count_to_bytes = bcs::to_bytes(&count(self));
vector::append(&mut vrf_input, count_to_bytes);
increment(self);
vrf_input
}
public fun count(self: &Counter): u64 {
self.count
}
fun increment(self: &mut Counter) {
self.count = self.count + 1;
}
#[test_only]
public fun burn_for_testing(self: Counter) {
burn(self);
}
}
get_vrf_input_and_increment
函数是此模块的核心。该函数接受一个对 mint
函数创建的 Counter
对象的可变引用,然后将 Counter
对象的当前计数附加到其 ID 中,并将结果作为 vector<u8>
返回。然后,该函数调用内部的 count
函数以将计数增加一。
此代码还添加了一个 count
函数,用于返回当前计数,以及一个仅供测试的函数,该函数调用 burn
函数。
游戏模块
最后,你需要一个游戏模块和对象,可以创建新游戏,在游戏结束后分发资金,并可能取消游戏。由于这是一个单人游戏,因此创建一个地址拥有对象(address-owned object),而不是共享对象。
创建游戏模块。在 sources
目录中,创建一个名为 single_player_satoshi.move
的新文件,并填充以下内容:
module satoshi_flip::single_player_satoshi {
use std::string::{Self, String};
use std::vector;
use sui::coin::{Self, Coin};
use sui::balance::{Self, Balance};
use sui::sui::SUI;
use sui::bls12381::bls12381_min_pk_verify;
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::event::emit;
use sui::hash::{blake2b256};
use sui::dynamic_object_field::{Self as dof};
use satoshi_flip::counter_nft::{Self, Counter};
use satoshi_flip::house_data::{Self as hd, HouseData};
const EPOCHS_CANCEL_AFTER: u64 = 7;
const GAME_RETURN: u8 = 2;
const PLAYER_WON_STATE: u8 = 1;
const HOUSE_WON_STATE: u8 = 2;
const CHALLENGED_STATE: u8 = 3;
const HEADS: vector<u8> = b"H";
const TAILS: vector<u8> = b"T";
const EStakeTooLow: u64 = 0;
const EStakeTooHigh: u64 = 1;
const EInvalidBlsSig: u64 = 2;
const ECanNotChallengeYet: u64 = 3;
const EInvalidGuess: u64 = 4;
const EInsufficientHouseBalance: u64 = 5;
const EGameDoesNotExist: u64 = 6;
struct NewGame has copy, drop {
game_id: ID,
player: address,
vrf_input: vector<u8>,
guess: String,
user_stake: u64,
fee_bp: u16
}
struct Outcome has copy, drop {
game_id: ID,
status: u8
}
此代码遵循与其他代码相同的模式。首先,你包含了相应的导入,尽管这一次导入不仅来自标准库,还包括在本示例中先前创建的模块。你还创建了几个常量(大写形式),以及用于错误的常量(以 E
为前缀的 Pascal 形式)。
最后,在此部分中,你还创建了两个事件(events)用于发射。索引器消耗发射的事件,这使你能够通过 API 服务或你自己的索引器跟踪这些事件。在本例中,事件用于在游戏开始时(NewGame
)和游戏结束时(Outcome
)。
向模块添加一个结构体:
struct Game has key, store {
id: UID,
guess_placed_epoch: u64,
total_stake: Balance<SUI>,
guess: String,
player: address,
vrf_input: vector<u8>,
fee_bp: u16
}
Game
结构体表示单个游戏及其所有信息,包括玩家下注的纪元(guess_placed_epoch
),下注额(total_stake
),guess
,player
的地址,vrf_input
和房屋收取的费用(fee_bp
)。
现在看一下这个游戏中的主要函数 finish_game
:
public fun finish_game(game_id: ID, bls_sig: vector<u8>, house_data: &mut HouseData, ctx: &mut TxContext) {
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
let Game {
id,
guess_placed_epoch: _,
total_stake,
guess,
player,
vrf_input,
fee_bp
} = dof::remove<ID, Game>(hd::borrow_mut(house_data), game_id);
object::delete(id);
// Step 1: Check the BLS signature, if its invalid abort.
let is_sig_valid = bls12381_min_pk_verify(&bls_sig, &hd::public_key(house_data), &vrf_input);
assert!(is_sig_valid, EInvalidBlsSig);
let hashed_beacon = blake2b256(&bls_sig);
// Step 2: Determine winner.
let first_byte = *vector::borrow(&hashed_beacon, 0);
let player_won = map_guess(guess) == (first_byte % 2);
// Step 3: Distribute funds based on result.
let status = if (player_won) {
// Step 3.a: If player wins transfer the game balance as a coin to the player.
// Calculate the fee and transfer it to the house.
let stake_amount = balance::value(&total_stake);
let fee_amount = fee_amount(stake_amount, fee_bp);
let fees = balance::split(&mut total_stake, fee_amount);
balance::join(hd::borrow_fees_mut(house_data), fees);
// Calculate the rewards and take it from the game stake.
transfer::public_transfer(coin::from_balance(total_stake, ctx), player);
PLAYER_WON_STATE
} else {
// Step 3.b: If house wins, then add the game stake to the house_data.house_balance (no fees are taken).
balance::join(hd::borrow_balance_mut(house_data), total_stake);
HOUSE_WON_STATE
};
emit(Outcome {
game_id,
status
});
}
- 首先,函数确保
Game
对象存在,然后将其删除,因为游戏结束后不再需要元数据。释放不必要的存储不仅是建议的,而且通过存储费用回扣是有激励的。 - 在步骤 1 中,函数检查 BLS 签名是否有效。这是为了确保游戏是真正随机的。
- 在步骤 2 中,函数检查玩家的猜测,是正面(
0
)还是反面(1
),是否与房屋的猜测相同。这通过取随机向量的第一个字节并检查它是否可被二整除来完成。如果是,它是正面;如果不是,它是反面。 - 在步骤 3 中,如果玩家赢了,意味着玩家的猜测与房屋的结果相匹配,逻辑将费用从赌注转移到房屋,然后将剩余的本金和来自房屋余额的相等金额返回给玩家。如果玩家输了,逻辑将整个赌注转移到房屋,不收取任何费用。
- 最后,游戏将其结果作为事件发射。
现在添加一个处理游戏争议的函数:
public fun dispute_and_win(house_data: &mut HouseData, game_id: ID, ctx: &mut TxContext) {
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
let Game {
id,
guess_placed_epoch,
total_stake,
guess: _,
player,
vrf_input: _,
fee_bp: _
} = dof::remove(hd::borrow_mut(house_data), game_id);
object::delete(id);
let caller_epoch = tx_context::epoch(ctx);
let cancel_epoch = guess_placed_epoch + EPOCHS_CANCEL_AFTER;
assert!(cancel_epoch <= caller_epoch, ECanNotChallengeYet);
transfer::public_transfer(coin::from_balance(total_stake, ctx), player);
emit(Outcome {
game_id,
status: CHALLENGED_STATE
});
}
此函数 dispute_and_win
确保没有赌注会处于“炼狱”状态。在经过一定时间后,玩家可以调用此函数并取回其所有资金。
其余的函数是用于检索值、检查值是否存在、初始化游戏等的访问器和辅助函数:
// --------------- Read-only References ---------------
public fun guess_placed_epoch(game: &Game): u64 {
game.guess_placed_epoch
}
public fun stake(game: &Game): u64 {
balance::value(&game.total_stake)
}
public fun guess(game: &Game): u8 {
map_guess(game.guess)
}
public fun player(game: &Game): address {
game.player
}
public fun vrf_input(game: &Game): vector<u8> {
game.vrf_input
}
public fun fee_in_bp(game: &Game): u16 {
game.fee_bp
}
// --------------- Helper functions ---------------
/// Public helper function to calculate the amount of fees to be paid.
public fun fee_amount(game_stake: u64, fee_in_bp: u16): u64 {
((((game_stake / (GAME_RETURN as u64)) as u128) * (fee_in_bp as u128) / 10_000) as u64)
}
/// Public helper function to check if a game exists.
public fun game_exists(house_data: &HouseData, game_id: ID): bool {
dof::exists_(hd::borrow(house_data), game_id)
}
/// Public helper function to check that a game exists and return a reference to the game Object.
public fun borrow_game(game_id: ID, house_data: &HouseData): &Game {
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
dof::borrow(hd::borrow(house_data), game_id)
}
/// Internal helper function used to create a new game
fun internal_start_game(guess: String, counter: &mut Counter, coin: Coin<SUI>, house_data: &mut HouseData, fee_bp: u16, ctx: &mut TxContext): (ID, Game) {
map_guess(guess);
let user_stake = coin::value(&coin);
assert!(user_stake <= hd::max_stake(house_data), EStakeTooHigh);
assert!(user_stake >= hd::min_stake(house_data), EStakeTooLow);
assert!(hd::balance(house_data) >= user_stake, EInsufficientHouseBalance);
let total_stake = balance::split(hd::borrow_balance_mut(house_data), user_stake);
coin::put(&mut total_stake, coin);
let vrf_input = counter_nft::get_vrf_input_and_increment(counter);
let id = object::new(ctx);
let game_id = object::uid_to_inner(&id);
let new_game = Game {
id,
guess_placed_epoch: tx_context::epoch(ctx),
total_stake,
guess,
player: tx_context::sender(ctx),
vrf_input,
fee_bp
};
emit(NewGame {
game_id,
player: tx_context::sender(ctx),
vrf_input,
guess,
user_stake,
fee_bp
});
(game_id, new_game)
}
/// Internal helper function to map (H)EADS and (T)AILS to 0 and 1 respectively
fun map_guess(guess: String): u8 {
assert!(string::bytes(&guess) == &HEADS || string::bytes(&guess) == &TAILS, EInvalidGuess);
if (string::bytes(&guess) == &HEADS) {
0
} else {
1
}
}
}
这是一个在 Move 中实现的简单硬币翻转后端的基本示例。游戏模块 single_player_satoshi
容易受到 MEV 攻击,但对于玩家来说,用户体验更为简化。还存在另一个示例游戏模块 mev_attack_resistant_single_player_satoshi
,它对 MEV 具有抵抗力,但用户体验略有降级(每个游戏需要两个玩家交易)。
你可以在 Satoshi Coin Flip 存储库 中详细了解这两个版本的游戏,并查看所有模块的完整源代码。
现在你已经编写了我们的合同,是时候部署它们了。
部署
想要了解更多关于发布包的信息,请参阅 Publish a Package 的详细指南。或者,您可以访问 Sui 客户端 CLI 了解 Sui CLI 中 client 命令的全部信息。
在发布代码前,请先初始化 Sui 客户端 CLI。在项目根目录的终端或控制台中,输入 sui client
。若出现如下提示,请按照指示操作:
Config file ["<FILE-PATH>/.sui/sui_config/client.yaml"] doesn't exist, do you want to connect to a Sui Full node server [y/N]?
输入 y
来继续。接下来您会看到:
Sui Full node server URL (Defaults to Sui Devnet if not specified) :
此处可直接按 Enter
键跳过。然后会出现:
Select key scheme to generate keypair (0 for ed25519, 1 for secp256k1, 2: for secp256r1):
输入 0
。此时,您应该已经成功创建了一个 Sui 地址。
在您把包发布到 Devnet 上之前,需要先获取一些 Devnet SUI 代币。您可以通过加入 Sui Discord 社区,完成验证后,在 #devnet-faucet
频道输入 !faucet <WALLET ADDRESS>
来获取代币。想了解更多获取 Devnet 账户中 SUI 的方法,请参阅 获取 SUI 代币。
一旦您的账户中有了 Devnet SUI 代币,就可以开始部署合约了。要发布您的包,请在终端或控制台中输入以下命令:
sui client publish --gas-budget <GAS-BUDGET>
在设置 Gas 费用时,可以使用标准值,比如 20000000
。
该包应该成功部署。 现在,是时候创建一个可以与之交互的前端了。
前端
前端的完整源代码可在 Satoshi Coin Flip Frontend 示例存储库 中找到。
为了向用户公开你创建的后端,你需要一个前端 (UI)。 在本部分中,你将使用 Sui Typescript SDK 和 Sui dApp Kit 创建一个 React 前端项目 kit 与已部署的智能合约进行交互。
初始化项目
以下说明使用“pnpm”作为包管理器。 如果需要,请按照 pnpm
安装说明 进行操作。
首先,初始化你的前端项目。 要快速完成此操作,请使用 create-dapp
工具 使用 [dApp Kit](https://sdk. mystenlabs.com/dapp-kit)。 在终端或控制台中运行以下命令:
pnpm create @mysten/dapp
这个 CLI 命令会引导你完成几个步骤:
它会询问你想要使用的起始模板。目前有两个变体:
react-client-dapp
:此起始模板包含你可以开始使用的最小 dApp Kit 模板代码。此变体适用于已经熟悉 dApp Kit 的开发人员,不希望有不必要的模板代码。react-e2e-counter
:此起始模板包含一个简单的 Counter Sui Move 智能合约,与前端模板代码互动。此变体适用于试图学习如何使用 dApp Kit 的开发人员。
它会提示你命名项目文件夹。
完成。你的项目已经具备所有必要的代码,可以开始使用。最后,cd
进入你的项目文件夹,并运行 pnpm install
安装所有依赖项。
用户界面布局设计
此前端示例的用户界面(UI)演示了如何使用 dApp Kit,而不是作为一个生产级产品,因此玩家和房屋功能在同一个 UI 中以简化流程。在生产解决方案中,你的前端将仅包含专用于玩家的功能,而后端服务将执行与智能合约中的房屋功能的交互。
UI 有两列:
- 第一列专用于玩家,所有与玩家相关的功能都在这里
- 第二列专用于房屋,所有与房屋相关的功能都在这里
项目文件夹结构
根据 UI 布局来构建项目文件夹结构,这意味着所有与玩家相关的 React 组件都位于 containers/Player
文件夹中,而所有与房屋相关的 React 组件都位于 containers/House
文件夹中。
探索代码
UI 与游戏的 单人玩家智能合约 变体进行交互。本节将引导你了解智能合约流程的每个步骤以及相应的前端代码。
以下前端代码片段仅包含最相关的部分。完整的源代码请参阅 Satoshi Coin Flip 前端示例存储库。
与其他 React 项目一样,App.tsx
是你实现外部布局的地方:
import { ConnectButton, useCurrentAccount } from '@mysten/dapp-kit';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Box, Callout, Container, Flex, Grid, Heading } from '@radix-ui/themes';
import { HOUSECAP_ID, PACKAGE_ID } from './constants';
import { HouseSesh } from './containers/House/HouseSesh';
import { PlayerSesh } from './containers/Player/PlayerSesh';
function App() {
const account = useCurrentAccount();
return (
<>
<Flex
position="sticky"
px="4"
py="2"
justify="between"
style={{
borderBottom: '1px solid var(--gray-a2)',
}}
>
<Box>
<Heading>Satoshi Coin Flip Single Player</Heading>
</Box>
<Box>
<ConnectButton />
</Box>
</Flex>
<Container>
<Heading size="4" m={'2'}>
Package ID: {PACKAGE_ID}
</Heading>
<Heading size="4" m={'2'}>
HouseCap ID: {HOUSECAP_ID}
</Heading>
<Callout.Root mb="2">
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>
You need to connect to wallet that publish the smart contract package
</Callout.Text>
</Callout.Root>
{!account ? (
<Heading size="4" align="center">
Please connect wallet to continue
</Heading>
) : (
<Grid columns="2" gap={'3'} width={'auto'}>
<PlayerSesh />
<HouseSesh />
</Grid>
)}
</Container>
</>
);
}
export default App;
与其他 dApp 一样,你需要一个 "连接钱包" 按钮来启用用户连接钱包。dApp Kit 包含一个预制的 ConnectButton
React 组件,你可以重用以帮助用户入门。
useCurrentAccount()
是 dApp Kit 提供的 React hook,用于查询当前连接的钱包;如果没有钱包连接,则返回 null
。利用这个行为,防止用户在尚未连接钱包的情况下继续操作。
有两个常量需要放入 constants.ts
中才能使应用程序正常工作 – PACKAGE_ID
和 HOUSECAP_ID
。你可以在终端或控制台中运行 Sui CLI 命令发布包后获取这些常量。
在确保用户已连接其钱包后,你可以显示前面部分描述的两个列:PlayerSesh
和 HouseSesh
组件。
好的,这是一个很好的开始,可以概览项目了。现在是时候转到初始化 HouseData
对象。调用此对象的所有前端逻辑都位于 HouseInitialize.tsx
组件中。该组件包含 UI 代码,但执行事务的逻辑如下:
<form
onSubmit={(e) => {
e.preventDefault();
// Create new transaction block
const txb = new TransactionBlock();
// Split gas coin into house stake coin
// SDK will take care for us abstracting away of up-front coin selections
const [houseStakeCoin] = txb.splitCoins(txb.gas, [
MIST_PER_SUI * BigInt(houseStake),
]);
// Calling smart contract function
txb.moveCall({
target: `${PACKAGE_ID}::house_data::initialize_house_data`,
arguments: [
txb.object(HOUSECAP_ID),
houseStakeCoin,
// This argument is not an on-chain object, hence, we must serialize it using `bcs`
// https://sdk.mystenlabs.com/typescript/transaction-building/basics#pure-values
txb.pure(
bcs
.vector(bcs.U8)
.serialize(curveUtils.hexToBytes(getHousePubHex())),
),
],
});
execInitializeHouse(
{
transactionBlock: txb,
options: {
showObjectChanges: true,
},
},
{
onError: (err) => {
toast.error(err.message);
},
onSuccess: (result: SuiTransactionBlockResponse) => {
let houseDataObjId;
result.objectChanges?.some((objCh) => {
if (
objCh.type === "created" &&
objCh.objectType === `${PACKAGE_ID}::house_data::HouseData`
) {
houseDataObjId = objCh.objectId;
return true;
}
});
setHouseDataId(houseDataObjId!);
toast.success(`Digest: ${result.digest}`);
},
},
);
}}
要在 Sui 中使用可编程事务块(PTB),请创建一个 TransactionBlock
。要启动 Move 调用,你必须知道智能合约中公共函数的全局标识符。全局标识符通常具有以下形式:
${PACKAGE_ID}::${MODULE_NAME}::${FUNCTION_NAME}
在这个例子中是:
${PACKAGE_ID}::house_data::initialize_house_data
在 initialize_house_data()
Move 函数中,有几个参数需要传递:HouseCap
ID、房屋赌注和房屋 BLS 公钥:
- 从
constants.ts
导入HouseCap
ID,这是在前面部分设置的。 - 对于房屋赌注,使用
TransactionBlock::splitCoin
来创建一个新的币,其金额定义为从 Gas Cointxb.gas
中拆分的。将 gas coin 视为你账户中用于支付燃料的一个币(可能覆盖账户的整个余额)。这对于 Sui 支付非常有用 - 不需要手动选择用于燃料支付的币或手动拆分/合并以获得正确金额的币,gas coin 是这个过程的单一入口点,所有繁重的工作都在幕后由 SDK 完成。 - 将 BLS 公钥传递为字节
vector<u8>
。当提供不是链上对象的输入时,使用txb.pure
和从@mysten/sui.js/bcs
导入的bcs
进行 BCS 序列化。
现在签署并执行事务块。dApp Kit 提供了一个 React hook useSignAndExecuteTransactionBlock()
来简化这个过程。当执行此 hook 时,它会提示 UI 让你批准、签署和执行事务块。你可以配置 hook 的 showObjectChanges
选项,以返回新创建的 HouseData
共享对象作为事务块的结果。此 HouseData
对象很重要,因为你将其用作后续 Move 调用的输入,因此请将其 ID 保存在某个地方。
很好,现在你知道如何初始化 HouseData
共享对象。转到下一个函数调用。
在这个游戏中,用户必须创建一个 Counter
对象才能开始游戏。因此,在 Player 列 UI 中应该有一个地方列出现有的 Counter
对象信息,以供玩家选择。你很可能会在 UI 中的多个位置重复使用 Counter
对象的获取逻辑,因此将此逻辑隔离到一个 React hook 中是一个良好的实践,你可以在 useFetchCounterNft.ts
中调用它,命名为 useFetchCounterNft()
:
import { useCurrentAccount, useSuiClientQuery } from '@mysten/dapp-kit';
import 'react';
import { PACKAGE_ID } from '../../constants';
// React hook to fetch CounterNFT owned by connected wallet
// This hook is to demonstrate how to use `@mysten/dapp-kit` React hook to query data
// besides using SuiClient directly
export function useFetchCounterNft() {
const account = useCurrentAccount();
if (!account) {
return { data: [] };
}
// Fetch CounterNFT owned by current connected wallet
// Only fetch the 1st one
const { data, isLoading, isError, error, refetch } = useSuiClientQuery(
'getOwnedObjects',
{
owner: account.address,
limit: 1,
filter: {
MatchAll: [
{
StructType: `${PACKAGE_ID}::counter_nft::Counter`,
},
{
AddressOwner: account.address,
},
],
},
options: {
showOwner: true,
showType: true,
},
},
{ queryKey: ['CounterNFT'] },
);
return {
data: data && data.data.length > 0 ? data?.data : [],
isLoading,
isError,
error,
refetch,
};
}
这个 hook 的逻辑非常基础:如果没有当前连接的钱包,返回空数据;否则,获取 Counter
对象并返回它。dApp Kit 提供了一个 React hook,useSuiClientQuery()
,用于与 Sui RPC 方法交互。不同的 RPC 方法需要不同的参数。要获取已知地址拥有的对象,请使用 getOwnedObjects
查询。
现在,传递已连接钱包的地址,以及 Counter
的全局标识符。这与函数调用的全局标识符类型格式相似:
${PACKAGE_ID}::counter_nft::Counter
就是这样,现在将该 hook 放入 UI 组件 PlayerListCounterNft.tsx
中并显示数据:
export function PlayerListCounterNft() {
const { data, isLoading, error, refetch } = useFetchCounterNft();
const { mutate: execCreateCounterNFT } = useSignAndExecuteTransactionBlock();
return (
<Container mb={'4'}>
<Heading size="3" mb="2">
Counter NFTs
</Heading>
{error && <Text>Error: {error.message}</Text>}
<Box mb="3">
{data.length > 0 ? (
data.map((it) => {
return (
<Box key={it.data?.objectId}>
<Text as="div" weight="bold">
Object ID:
</Text>
<Text as="div">{it.data?.objectId}</Text>
<Text as="div" weight="bold">
Object Type:
</Text>
<Text as="div">{it.data?.type}</Text>
</Box>
);
})
) : (
<Text>No CounterNFT Owned</Text>
)}
</Box>
</Container>
);
}
对于没有现有 Counter
对象的情况,为连接的钱包铸造一个新的 Counter
。当用户点击按钮时,还要在 PlayerListCounterNft.tsx
中添加铸造逻辑。你已经知道如何使用 TransactionBlock
和 initialize_house_data()
构建和执行 Move 调用,你可以在这里实现类似的调用。
正如你可能还记得的那样,使用 TransactionBlock
,事务的输出可以成为下一个事务的输入。调用 counter_nft::mint()
,它会返回新创建的 Counter
对象,并将其用作 counter_nft::transfer_to_sender()
的输入,以将 Counter
对象转移到调用者钱包:
const txb = new TransactionBlock();
const [counterNft] = txb.moveCall({
target: `${PACKAGE_ID}::counter_nft::mint`,
});
txb.moveCall({
target: `${PACKAGE_ID}::counter_nft::transfer_to_sender`,
arguments: [counterNft],
});
execCreateCounterNFT(
{
transactionBlock: txb,
},
{
onError: (err) => {
toast.error(err.message);
},
onSuccess: (result) => {
toast.success(`Digest: ${result.digest}`);
refetch?.();
},
},
);
好的,现在你可以使用创建的 Counter
对象创建游戏。将游戏创建逻辑隔离到 PlayerCreateGame.tsx
中。还有一件事要记住 - 要将输入标记为链上对象,你应该使用 txb.object()
以及相应的对象 ID。
// Create new transaction block
const txb = new TransactionBlock();
// Player stake
const [stakeCoin] = txb.splitCoins(txb.gas, [MIST_PER_SUI * BigInt(stake)]);
// Create the game with CounterNFT
txb.moveCall({
target: `${PACKAGE_ID}::single_player_satoshi::start_game`,
arguments: [
txb.pure.string(guess),
txb.object(counterNFTData[0].data?.objectId!),
stakeCoin,
txb.object(houseDataId),
],
});
execCreateGame(
{
transactionBlock: txb,
},
{
onError: (err) => {
toast.error(err.message);
},
onSuccess: (result: SuiTransactionBlockResponse) => {
toast.success(`Digest: ${result.digest}`);
},
},
);
最后一步尚未完成:结算游戏。你可以使用 UI 有两种方式来完成结算游戏:
- 创建一个“结算游戏”按钮,并将所有必要的参数传递给
single_player_satoshi::finish_game()
Move 调用。 - 通过事件订阅自动结算游戏。此示例使用此路径来教授关于事件的良好实践以及如何订阅它们。
所有这些逻辑都在 HouseFinishGame.tsx
中:
// This component will help the House to automatically finish the game whenever new game is started
export function HouseFinishGame() {
const suiClient = useSuiClient();
const { mutate: execFinishGame } = useSignAndExecuteTransactionBlock();
const [housePrivHex] = useContext(HouseKeypairContext);
const [houseDataId] = useContext(HouseDataContext);
useEffect(() => {
// Subscribe to NewGame event
const unsub = suiClient.subscribeEvent({
filter: {
MoveEventType: `${PACKAGE_ID}::single_player_satoshi::NewGame`,
},
onMessage(event) {
console.log(event);
const { game_id, vrf_input } = event.parsedJson as {
game_id: string;
vrf_input: number[];
};
toast.info(`NewGame started ID: ${game_id}`);
console.log(housePrivHex);
try {
const houseSignedInput = bls.sign(
new Uint8Array(vrf_input),
curveUtils.hexToBytes(housePrivHex),
);
// Finish the game immediately after new game started
const txb = new TransactionBlock();
txb.moveCall({
target: `${PACKAGE_ID}::single_player_satoshi::finish_game`,
arguments: [
txb.pure.id(game_id),
txb.pure(bcs.vector(bcs.U8).serialize(houseSignedInput)),
txb.object(houseDataId),
],
});
execFinishGame(
{
transactionBlock: txb,
},
{
onError: (err) => {
toast.error(err.message);
},
onSuccess: (result: SuiTransactionBlockResponse) => {
toast.success(`Digest: ${result.digest}`);
},
},
);
} catch (err) {
console.error(err);
}
},
});
return () => {
(async () => (await unsub)())();
};
}, [housePrivHex, houseDataId, suiClient]);
return null;
}
要从 SDK 中获取底层的 SuiClient
实例,请使用 useSuiClient()
。每当 HouseFinishGame
组件加载时,你希望订阅事件。为此,请使用核心 React 库中的 React hook useEffect()
。
SuiClient
提供了一个名为 subscribeEvent()
的方法,可以让你订阅各种事件类型。实际上,SuiClient::subscribeEvent()
是对 RPC 方法 suix_subscribeEvent
的薄包装。
逻辑是每当开始新游戏时,你希望立即结算游戏。要实现这一点,所需的事件是名为 single_player_satoshi::NewGame
的 Move 事件类型。如果通过 event.parsedJson
检查事件的解析有效载荷,你可以看到在智能合约中声明的相应事件字段。在这种情况下,你只需使用两个字段,即 Game ID 和 VRF 输入。
接下来的步骤与前面的 Move 调用类似,但你必须使用 BLS 私钥对 VRF 输入进行签名,然后将 Game ID、签名后的 VRF 输入和 HouseData
ID 传递给 single_player_satoshi::finish_game()
Move 调用。
最后但同样重要的是,记得在 HouseFinishGame
组件卸载时取消订阅事件。这很重要,因为你可能不希望多次订阅相同的事件。
恭喜,你完成了前端。在构建下一个 Sui 项目时,可以将在这里学到的经验应用到使用 dApp Kit 的过程中。