Coin Flip
This guide demonstrates writing a module (smart contract) in Move, deploying it on Devnet, and adding a TypeScript frontend to communicate with the module.
Satoshi Coin Flip is a dApp that utilizes verifiable random functions (VRFs) to create a fair coin game on the Sui blockchain. The user (human) plays against the house (module) and places a bet on either heads or tails. The user then either receives double their bet, or gets nothing, depending on the outcome of the game.
This guide assumes you have installed Sui and understand Sui fundamentals.
Backend
As with all Sui dApps, a Move package on chain powers the logic of Satoshi Coin Flip. The following instruction walks you through creating and publishing the module.
House module
This example uses several modules to create a package for the Satoshi Coin Flip game. The first module is house_data.move
. You need to store the game’s data somewhere, and in this module you create a shared object for all house data.
The full source code for the Move modules, including comments and on overview of its cryptography, is available at the Satoshi Coin Flip repository.
Before you get started, you must initialize a Move package. Open a terminal or console in the directory you want to store the example and run the following command to create an empty package with the name satoshi_flip
:
sui move new satoshi_flip
With that done, it's time to jump into some code. Create a new file in the sources
directory with the name house_data.move
and populate the file with the following code:
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;
There are few details to take note of in this code:
- The first line declares the module name as
house_data
within the packagesatoshi_flip
. - Seven lines begin with the
use
keyword, which enables this module to use types and functions declared in other modules (in this case, they are all coming from the Sui standard library). - Two error codes. These codes are used in assertions and unit tests to ensure that the program is running as intended.
- Two
friend
, or trusted, modules.
Next, add some more code to this module:
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));
}
- The first struct,
HouseData
, stores the most essential information pertaining to the game. - The second struct,
HouseCap
, is a capability that initializes the house data. - The third struct,
HOUSE_DATA
, is a one-time witness that ensures only a single instance of thisHouseData
ever exists. - The
init
function creates and sends thePublisher
andHouseCap
objects to the sender.
So far, you've set up the data structures within the module. Now, create a function that initializes the house data and shares the HouseData
object:
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);
}
With the house data initialized, you also need to add some functions that enable some important administrative tasks for the house to perform:
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;
}
All of these functions contain an assert!
call that ensures only the house can call them:
top_up
: Add to the balance of the house to ensure that there is enough SUI for future games.withdraw
: Withdraw the entire balance of the house object.claim_fees
: Withdraw the accumulated fees of the house object.update_max_stake
,update_min_stake
: Update the maximum and minimum stake allowed in the game, respectively.
You have established the data structure of this module, but without the appropriate functions this data is not accessible. Now add helper functions that return mutable references, read-only references, and test-only functions:
// --------------- 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);
}
}
And with that, your house_data.move
code is complete.
Counter module
In the same sources
directory, now create a file named counter_nft.move
. A Counter
object is used as the VRF input for every game that a player plays. First, populate the file with the following:
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));
}
This might look familiar from the house module. You set the module name, import functions from the standard library, and initialize the Counter
object. The Counter
object has the key
ability, but does not have store
- this prevents the object from being transferable.
In addition, you create mint
and transfer_to_sender
functions used when the game is set up to create the Counter
object (with an initial count of 0
) and transfer the object to the sender of the transaction. And finally a burn
function to allow deletion of the Counter
.
You have a Counter
object, as well as functions that initialize and burn the object, but you need a way to increment the counter. Add the following code to the module:
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);
}
}
The get_vrf_input_and_increment
function is the core of this module. The function takes a mutable reference to the Counter
object that the mint
function creates, then appends the Counter
object's current count to its ID and returns the result as a vector<u8>
. The function then calls the internal count
function to increment the count by one.
This code also adds a count
function that returns the current count, and a test-only function that calls the burn
function.
Game module
Lastly, you need a game module and object that can create a new game, distribute funds after the game, and potentially cancel games. Because this is a one-player game, create an address-owned object rather than a shared object.
Create the game module. In the sources
directory, create a new file called single_player_satoshi.move
and populate with the following:
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
}
This code follows the same pattern as the others. First, you include the respective imports, although this time the imports are not only from the standard library but also include modules created previously in this example. You also create several constants (in upper case), as well as constants used for errors (Pascal case prefixed with E
).
Lastly in this section, you also create structs for two events to emit. Indexers consume emitted events, which enables you to track these events through API services, or your own indexer. In this case, the events are for when a new game begins (NewGame
) and for the outcome of a game when it has finished (Outcome
).
Add a struct to the module:
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
}
The Game
struct represents a single game and all its information, including the epoch the player placed the bet (guess_placed_epoch
), bet (total_stake
), guess
, address of the player
, vrf_input
, and the fee the house collects (fee_bp
).
Now take a look at the main function in this game, 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
});
}
- First, the function makes sure the
Game
object exists, then deletes it, as after the game concludes the metadata is no longer needed. Freeing up unnecessary storage is not only recommended, but incentivized through rebates on storage fees. - In step 1, the function checks to see if the BLS signature is valid. This is to ensure the game is truly random.
- In step 2, the function checks to see if the player’s guess, heads (
0
) or tails (1
), is the same as that of the house. This is done by taking the first byte of the randomized vector and checking to see if it’s divisible by two. If it is, it is heads, if it is not, it is tails. - In step 3, if the player won, meaning the player’s guess matched the results of the house, the logic transfers fees from the stake to the house, then distributes the rest of the principle plus an equal amount from the house’s balance back to the player. If the player loses, the logic transfers the entire stake to the house, and takes no fees.
- Lastly, the game emits its outcome as an event.
Now add a function that handles game disputes:
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
});
}
This function, dispute_and_win
, ensures that no bet can live in “purgatory”. After a certain amount of time passes, the player can call this function and get all of their funds back.
The rest of the functions are accessors and helper functions used to retrieve values, check if values exist, initialize the game, and so on:
// --------------- 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
}
}
}
This represents a basic example of a coin flip backend in Move. The game module, single_player_satoshi
, is prone to MEV attacks, but the user experience for the player is streamlined. Another example game module, mev_attack_resistant_single_player_satoshi
, exists that is MEV-resistant, but has a slightly downgraded user experience (two player-transactions per game).
You can read more about both versions of the game, and view the full source code for all the modules in the Satoshi Coin Flip repository.
Now that you have written our contracts, it's time to deploy them.
Deployment
See Publish a Package for a more detailed guide on publishing packages or Sui Client CLI for a complete reference of client
commands in the Sui CLI.
Before publishing your code, you must first initialize the Sui Client CLI. To do so, in a terminal or console at the root directory of the project enter sui client
. You will then see:
Config file ["[LINK_TO_PATH/.sui/sui_config/client.yaml"] doesn't exist, do you want to connect to a Sui Full node server [y/N]?
Enter y
to proceed. You then see:
Sui Full node server URL (Defaults to Sui Devnet if not specified) :
Leave this blank (press Enter). Then you see:
Select key scheme to generate keypair (0 for ed25519, 1 for secp256k1, 2: for secp256r1):
Select 0
. Now you should have a Sui address set up.
Before being able to publish your package to Devnet, however, you need Devnet SUI tokens. To get some, join the Sui Discord, complete the verification steps, enter the #devnet-faucet
channel and type !faucet <WALLET ADDRESS>
. For other ways to get SUI in your Devnet account, see Get SUI Tokens.
Now that you have an account with some Devnet SUI, you can deploy your contracts. To publish your package, use the following command in the same terminal or console:
sui client publish --gas-budget <GAS-BUDGET>
For the gas budget, use a standard value such as 20000000
.
The package should successfully deploy. Now, it's time to create a frontend that can interact with it.
Frontend
The full source code for the frontend is available at the Satoshi Coin Flip Frontend Example repository.
To expose the backend you have created to your users, you need a frontend (UI). In this section, you create a React frontend project using the Sui Typescript SDK and the Sui dApp Kit that interacts with the deployed smart contracts.
Initialize the project
The following instructions are using pnpm
as the package manager. Follow the pnpm
install instructions, if needed.
First, initialize your frontend project. To do this rapidly, use the create-dapp
tool to bootstrap the project using dApp Kit. Run the following command in your terminal or console:
pnpm create @mysten/dapp
This CLI command prompts you through a couple of steps:
It asks you the starter template that you want to use. Currently, there are two variants:
react-client-dapp
: This starter template contains the minimum dApp Kit template code that you can start with. This variant is meant for developers already familiar with the dApp Kit and who don't want unnecessary template code.react-e2e-counter
: This starter template contains a simple counter Sui Move smart contract with the frontend template code interacting with it. This variant is meant for developers trying to learn how to use dApp Kit.
It prompts you to name your project folder.
Done. Your project has all necessary code to get you started. Lastly, cd
into your project folder and run pnpm install
to install all dependencies.
User interface layout design
The user interface (UI) of this frontend example demonstrates how to use the dApp Kit instead of serving as a production-grade product, so the Player and the House features are in the same UI to simplify the process. In a production solution, your frontend would only contain functionality dedicated to the Player, with a backend service carrying out the interactions with House functions in the smart contracts.
The UI has two columns:
- First column is dedicated to the Player, and all Player-related features live there
- Second column is dedicated to the House, and all House-related features live there
Project folder structure
Structure the project folder according to the UI layout, meaning that all Player-related React components reside in the containers/Player
folder, while all House-related React components reside in the containers/House
folder.
Exploring the code
The UI interacts with the Single Player smart contract variant of the game. This section walks you through each step in the smart contract flow and the corresponding frontend code.
The following frontend code snippets include only the most relevant sections. Refer to the Satoshi Coin Flip Frontend Example repository for complete source code.
As is common in other React projects, App.tsx
is where you implement the outer layout:
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;
Like other dApps, you need a "connect wallet" button to enable connecting users' wallets. dApp Kit contains a pre-made ConnectButton
React component that you can reuse to help users onboard.
useCurrentAccount()
is a React hook the dApp Kit also provides to query the current connected wallet; returning null
if there isn't a wallet connection. Leverage this behavior to prevent a user from proceeding further if they haven’t connected their wallet yet.
There are two constants that you need to put into constants.ts
to make the app work – PACKAGE_ID
and HOUSECAP_ID
. You can get these from the terminal or console after running the Sui CLI command to publish the package.
After ensuring that the user has connected their wallet, you can display the two columns described in the previous section: PlayerSesh
and HouseSesh
components.
Okay, that’s a good start to have an overview of the project. Time to move to initializing the HouseData
object. All the frontend logic for calling this lives in the HouseInitialize.tsx
component. The component includes UI code, but the logic that executes the transaction follows:
<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://sui-typescript-docs.vercel.app/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}`);
},
},
);
}}
To use a programmable transaction block (PTB) in Sui, create a TransactionBlock
. To initiate a Move call, you must know the global identifier of a public function in your smart contract. The global identifier usually takes the following form:
${PACKAGE_ID}::${MODULE_NAME}::${FUNCTION_NAME}
In this example, it is:
${PACKAGE_ID}::house_data::initialize_house_data
There are a few parameters that you need to pass into initialize_house_data()
Move function: the HouseCap
ID, the House stake, and the House BLS public key:
- Import the
HouseCap
ID fromconstants.ts
, which you set up in the previous section. - Use
TransactionBlock::splitCoin
for the House stake to create a new coin with a defined amount split from the Gas Cointxb.gas
. Think of the gas coin as one singular coin available for gas payment from your account (which might cover the entire remaining balance of your account). This is useful for Sui payments - instead of manually selecting the coins for gas payment or manually splitting/merging to have the coin with correct amount for your Move call, the gas coin is the single entry point for this, with all the heavy lifting delegated to the SDK behind the scenes. - Pass the BLS public key as bytes
vector<u8>
. When providing inputs that are not on-chain objects, serialize them as BCS using a combination oftxb.pure
andbcs
imported from@mysten/sui.js/bcs
.
Now sign and execute the transaction block. dApp Kit provides a React hook useSignAndExecuteTransactionBlock()
to streamline this process. This hook, when executed, prompts the UI for you to approve, sign, and execute the transaction block. You can configure the hook with the showObjectChanges
option to return the newly-created HouseData
shared object as the result of the transaction block. This HouseData
object is important as you use it as input for later Move calls, so save its ID somewhere.
Great, now you know how to initialize the HouseData
shared object. Move to the next function call.
In this game, the users must create a Counter
object to start the game. So there should be a place in the Player column UI to list the existing Counter
object information for the player to choose. It seems likely that you will reuse the fetching logic for the Counter
object in several places in your UI, so it’s good practice to isolate this logic into a React hook, which you call useFetchCounterNft()
in 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,
};
}
This hook logic is very basic: if there is no current connected wallet, return empty data; otherwise, fetch the Counter
object and return it. dApp Kit provides a React hook, useSuiClientQuery()
, that enables interaction with Sui RPC methods. Different RPC methods require different parameters. To fetch the object owned by a known address, use the getOwnedObjects
query.
Now, pass the address of the connected wallet, as well as the global identifier for the Counter
. This is in similar format to the global identifier type for function calls:
${PACKAGE_ID}::counter_nft::Counter
That’s it, now put the hook into the UI component PlayerListCounterNft.tsx
and display the data:
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>
);
}
For the case when there is no existing Counter
object, mint a new Counter
for the connected wallet. Also add the minting logic into PlayerListCounterNft.tsx
when the user clicks the button. You already know how to build and execute a Move call with TransactionBlock
and initialize_house_data()
, you can implement a similar call here.
As you might recall with TransactionBlock
, outputs from the transaction can be inputs for the next transaction. Call counter_nft::mint()
, which returns the newly created Counter
object, and use it as input for counter_nft::transfer_to_sender()
to transfer the Counter
object to the caller wallet:
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?.();
},
},
);
Great, now you can create the game with the created Counter
object. Isolate the game creation logic into PlayerCreateGame.tsx
. There is one more thing to keep in mind - to flag an input as an on-chain object, you should use txb.object()
with the corresponding 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}`);
},
},
);
One final step remains: settle the game. There are a couple of ways you can use the UI to settle the game:
- Create a Settle Game button and pass all the necessary arguments to the
single_player_satoshi::finish_game()
Move call. - Settle the game automatically through an events subscription. This example uses this path to teache good practices on events and how to subscribe to them.
All of this logic is in 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;
}
To get the underlying SuiClient
instance from the SDK, use useSuiClient()
. You want to subscribe to events whenever the HouseFinishGame
component loads. To do this, use the React hook useEffect()
from the core React library.
SuiClient
exposes a method called subscribeEvent()
that enables you to subscribe to a variety of event types. SuiClient::subscribeEvent()
is actually a thin wrapper around the RPC method suix_subscribeEvent
.
The logic is that whenever a new game starts, you want to settle the game immediately. The necessary event to achieve this is the Move event type called single_player_satoshi::NewGame
. If you inspect the parsed payload of the event through event.parsedJson
, you can see the corresponding event fields declared in the smart contract. In this case, you just need to use two fields, the Game ID and the VRF input.
The next steps are similar to the previous Move calls, but you have to use the BLS private key to sign the VRF input and then pass the Game ID, signed VRF input and HouseData
ID to the single_player_satoshi::finish_game()
Move call.
Last but not least, remember to unsubscribe from the event whenever the HouseFinishGame
component dismounts. This is important as you might not want to subscribe to the same event multiple times.
Congratulations, you completed the frontend. You can carry the lessons learned here forward when using the dApp Kit to build your next Sui project.