Skip to main content

抛硬币

本指南演示了在Move中编写模块 (智能合约),在Devnet上部署它,并添加一个TypeScript前端以与该模块进行通信。

Satoshi Coin Flip是一个dApp,利用可验证随机函数 (VRF) 在 Sui 区块链上创建一个公平的硬币游戏。用户(人类)与庄家(模块)进行游戏,并在正反面之间下注。然后,根据游戏的结果,用户要么获得其下注金额的两倍,要么什么都没有。

本指南假定你已经安装了Sui并了解Sui的基础知识。

后端

与所有Sui dApp一样,Satoshi Coin Flip的逻辑由链上的Move包提供支持。以下说明将带你完成创建和发布该模块的步骤。

House 模块

这个示例使用了多个模块来创建Satoshi Coin Flip游戏的包。第一个模块是house_data.move。你需要在某个地方存储游戏数据,在这个模块中,你为所有房屋数据创建了一个共享对象

info

Move模块的完整源代码,包括注释和其密码学概述,可以在Satoshi Coin Flip存储库中找到。

在开始之前,你必须初始化一个Move包。在你想要存储示例的目录中打开终端或控制台,并运行以下命令创建一个名为satoshi_flip的空包:

sui move new satoshi_flip

完成了这一步,现在是时候进入一些代码了。在sources目录中创建一个名为house_data.move的新文件,并使用以下代码填充该文件:

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;

在这段代码中,有一些细节需要注意:

  1. 第一行将模块命名为 house_data,属于 satoshi_flip 包。
  2. 七行以 use 关键字开头,允许此模块使用在其他模块中声明的类型和函数(在本例中,它们都来自 Sui 标准库)。
  3. 两个错误代码。这些代码用于断言和单元测试,以确保程序按预期运行。
  4. 两个 friend,或受信任的,模块

接下来,在此模块中添加一些代码:

house_data.move
    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 函数创建并发送 PublisherHouseCap 对象给发送者。

到目前为止,你已经在模块中设置了数据结构。现在,创建一个初始化房屋数据并分享 HouseData 对象的函数:

house_data.move
    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);
}

房屋数据已初始化,你还需要添加一些函数,以使房屋能够执行一些重要的行政任务:

house_data.move
    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_stakeupdate_min_stake:分别更新游戏中允许的最大和最小投注额。

你已经建立了该模块的数据结构,但没有适当的函数,这些数据是无法访问的。现在添加一些辅助函数,返回可变引用、只读引用和仅供测试的函数:

house_data.move
    // --------------- 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 输入。首先,用以下内容填充文件:

counter_nft.move
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 - 这可以防止对象被转移。

此外,你创建了在设置游戏时使用的 minttransfer_to_sender 函数,用于创建带有初始计数为 0Counter 对象并将对象转移到交易发送者。最后,创建一个 burn 函数以允许删除 Counter

现在,你有了一个 Counter 对象,以及初始化和销毁该对象的函数,但你需要一种递增计数器的方法。向模块添加以下代码:

counter_nft.move
    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 的新文件,并填充以下内容:

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)。

向模块添加一个结构体:

single_player_satoshi.move
    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),guessplayer 的地址,vrf_input 和房屋收取的费用(fee_bp)。

现在看一下这个游戏中的主要函数 finish_game

single_player_satoshi.move
    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 中,如果玩家赢了,意味着玩家的猜测与房屋的结果相匹配,逻辑将费用从赌注转移到房屋,然后将剩余的本金和来自房屋余额的相等金额返回给玩家。如果玩家输了,逻辑将整个赌注转移到房屋,不收取任何费用。
  • 最后,游戏将其结果作为事件发射。

现在添加一个处理游戏争议的函数:

single_player_satoshi.move
    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 确保没有赌注会处于“炼狱”状态。在经过一定时间后,玩家可以调用此函数并取回其所有资金。

其余的函数是用于检索值、检查值是否存在、初始化游戏等的访问器和辅助函数:

single_player_satoshi.move
    // --------------- 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 存储库 中详细了解这两个版本的游戏,并查看所有模块的完整源代码。

现在你已经编写了我们的合同,是时候部署它们了。

部署

info

想要了解更多关于发布包的信息,请参阅 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

该包应该成功部署。 现在,是时候创建一个可以与之交互的前端了。

前端

info

前端的完整源代码可在 Satoshi Coin Flip Frontend 示例存储库 中找到。

为了向用户公开你创建的后端,你需要一个前端 (UI)。 在本部分中,你将使用 Sui Typescript SDKSui dApp Kit 创建一个 React 前端项目 kit 与已部署的智能合约进行交互。

初始化项目

info

以下说明使用“pnpm”作为包管理器。 如果需要,请按照 pnpm 安装说明 进行操作。

首先,初始化你的前端项目。 要快速完成此操作,请使用 create-dapp 工具 使用 [dApp Kit](https://sdk. mystenlabs.com/dapp-kit)。 在终端或控制台中运行以下命令:

pnpm create @mysten/dapp

这个 CLI 命令会引导你完成几个步骤:

  1. 它会询问你想要使用的起始模板。目前有两个变体:

    1. react-client-dapp:此起始模板包含你可以开始使用的最小 dApp Kit 模板代码。此变体适用于已经熟悉 dApp Kit 的开发人员,不希望有不必要的模板代码。
    2. react-e2e-counter:此起始模板包含一个简单的 Counter Sui Move 智能合约,与前端模板代码互动。此变体适用于试图学习如何使用 dApp Kit 的开发人员。
  2. 它会提示你命名项目文件夹。

完成。你的项目已经具备所有必要的代码,可以开始使用。最后,cd 进入你的项目文件夹,并运行 pnpm install 安装所有依赖项。

用户界面布局设计

此前端示例的用户界面(UI)演示了如何使用 dApp Kit,而不是作为一个生产级产品,因此玩家和房屋功能在同一个 UI 中以简化流程。在生产解决方案中,你的前端将仅包含专用于玩家的功能,而后端服务将执行与智能合约中的房屋功能的交互。

UI 有两列:

  • 第一列专用于玩家,所有与玩家相关的功能都在这里
  • 第二列专用于房屋,所有与房屋相关的功能都在这里

项目文件夹结构

根据 UI 布局来构建项目文件夹结构,这意味着所有与玩家相关的 React 组件都位于 containers/Player 文件夹中,而所有与房屋相关的 React 组件都位于 containers/House 文件夹中。

探索代码

UI 与游戏的 单人玩家智能合约 变体进行交互。本节将引导你了解智能合约流程的每个步骤以及相应的前端代码。

info

以下前端代码片段仅包含最相关的部分。完整的源代码请参阅 Satoshi Coin Flip 前端示例存储库

与其他 React 项目一样,App.tsx 是你实现外部布局的地方:

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_IDHOUSECAP_ID。你可以在终端或控制台中运行 Sui CLI 命令发布包后获取这些常量。

在确保用户已连接其钱包后,你可以显示前面部分描述的两个列:PlayerSeshHouseSesh 组件。

好的,这是一个很好的开始,可以概览项目了。现在是时候转到初始化 HouseData 对象。调用此对象的所有前端逻辑都位于 HouseInitialize.tsx 组件中。该组件包含 UI 代码,但执行事务的逻辑如下:

containers/House/HouseInitialize.tsx
<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 Coin txb.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()

containers/Player/useFetchCounterNft.ts
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 中并显示数据:

containers/Player/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 中添加铸造逻辑。你已经知道如何使用 TransactionBlockinitialize_house_data() 构建和执行 Move 调用,你可以在这里实现类似的调用。

正如你可能还记得的那样,使用 TransactionBlock,事务的输出可以成为下一个事务的输入。调用 counter_nft::mint(),它会返回新创建的 Counter 对象,并将其用作 counter_nft::transfer_to_sender() 的输入,以将 Counter 对象转移到调用者钱包:

containers/Player/PlayerListCounterNft.tsx
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。

containers/Player/PlayerCreateGame.tsx
// 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 有两种方式来完成结算游戏:

  1. 创建一个“结算游戏”按钮,并将所有必要的参数传递给 single_player_satoshi::finish_game() Move 调用。
  2. 通过事件订阅自动结算游戏。此示例使用此路径来教授关于事件的良好实践以及如何订阅它们。

所有这些逻辑都在 HouseFinishGame.tsx 中:

containers/House/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 的过程中。