docs/content/guides/developer/app-examples/coin-flip.mdx
This example walks you through building a Coin Flip app, covering the full end-to-end flow of building your Sui Move module and connecting it to your React Sui app. This Coin Flip app 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.
The guide is split into two parts:
:::tip Additional resources
Source code locations for the smart contracts and frontend:
:::
HouseData object.HouseData object ever exists.In this part of the guide, you write the Move contracts that manage the house and set up the coin-flip logic. The first step is to set up a Move package for storing your Move modules.
:::info
To follow along with this guide, set your new Move package to satoshi_flip.
:::
house_data moduleThis 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.
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::balance::{Self, Balance};
use sui::sui::SUI;
use sui::coin::{Self, Coin};
use sui::package::{Self};
// Error codes
const ECallerNotHouse: u64 = 0;
const EInsufficientBalance: u64 = 1;
There are few details to take note of in this code:
house_data within the package satoshi_flip.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).Next, add some more code to this module:
/// Configuration and Treasury object, managed by the house.
public 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
}
/// A one-time use capability to initialize the house data; created and sent
/// to sender in the initializer.
public struct HouseCap has key {
id: UID
}
/// Used as a one time witness to generate the publisher.
public struct HOUSE_DATA has drop {}
fun init(otw: HOUSE_DATA, ctx: &mut TxContext) {
// Creating and sending the Publisher object to the sender.
package::claim_and_keep(otw, ctx);
// Creating and sending the HouseCap object to the sender.
let house_cap = HouseCap {
id: object::new(ctx)
};
transfer::transfer(house_cap, ctx.sender());
}
HouseData, stores the most essential information pertaining to the game.HouseCap, is a capability that initializes the house data.HOUSE_DATA, is a one-time witness that ensures only a single instance of this HouseData ever exists.init function creates and sends the Publisher and HouseCap objects to the sender. See Module Initializer in The Move Book for more information.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() > 0, EInsufficientBalance);
let house_data = HouseData {
id: object::new(ctx),
balance: coin.into_balance(),
house: ctx.sender(),
public_key,
max_stake: 50_000_000_000, // 50 SUI, 1 SUI = 10^9.
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) {
// Only the house address can withdraw funds.
assert!(ctx.sender() == house_data.house(), ECallerNotHouse);
let total_balance = balance(house_data);
let coin = coin::take(&mut house_data.balance, total_balance, ctx);
transfer::public_transfer(coin, house_data.house());
}
public fun claim_fees(house_data: &mut HouseData, ctx: &mut TxContext) {
// Only the house address can withdraw fee funds.
assert!(ctx.sender() == house_data.house(), ECallerNotHouse);
let total_fees = fees(house_data);
let coin = coin::take(&mut house_data.fees, total_fees, ctx);
transfer::public_transfer(coin, house_data.house());
}
public fun update_max_stake(house_data: &mut HouseData, max_stake: u64, ctx: &mut TxContext) {
// Only the house address can update the base fee.
assert!(ctx.sender() == house_data.house(), ECallerNotHouse);
house_data.max_stake = max_stake;
}
public fun update_min_stake(house_data: &mut HouseData, min_stake: u64, ctx: &mut TxContext) {
// Only the house address can update the min stake.
assert!(ctx.sender() == house_data.house(), 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(package) fun borrow_balance_mut(house_data: &mut HouseData): &mut Balance<SUI> {
&mut house_data.balance
}
public(package) fun borrow_fees_mut(house_data: &mut HouseData): &mut Balance<SUI> {
&mut house_data.fees
}
public(package) fun borrow_mut(house_data: &mut HouseData): &mut UID {
&mut house_data.id
}
// --------------- Read-only References ---------------
/// Returns a reference to the house id.
public(package) fun borrow(house_data: &HouseData): &UID {
&house_data.id
}
/// Returns the balance of the house.
public fun balance(house_data: &HouseData): u64 {
house_data.balance.value()
}
/// Returns the address of the house.
public fun house(house_data: &HouseData): address {
house_data.house
}
/// Returns the public key of the house.
public fun public_key(house_data: &HouseData): vector<u8> {
house_data.public_key
}
/// Returns the max stake of the house.
public fun max_stake(house_data: &HouseData): u64 {
house_data.max_stake
}
/// Returns the min stake of the house.
public fun min_stake(house_data: &HouseData): u64 {
house_data.min_stake
}
/// Returns the fees of the house.
public fun fees(house_data: &HouseData): u64 {
house_data.fees.value()
}
/// Returns the base fee.
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_nft moduleIn 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 sui::bcs::{Self};
public 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 mut vrf_input = object::id_bytes(self);
let count_to_bytes = bcs::to_bytes(&count(self));
vrf_input.append(count_to_bytes);
self.increment();
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) {
self.burn();
}
}
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 increment 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.
single_player_satoshi moduleLastly, 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::String;
use sui::coin::{Self, Coin};
use sui::balance::Balance;
use sui::sui::SUI;
use sui::bls12381::bls12381_min_pk_verify;
use sui::event::emit;
use sui::hash::{blake2b256};
use sui::dynamic_object_field::{Self as dof};
use satoshi_flip::counter_nft::Counter;
use satoshi_flip::house_data::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;
public struct NewGame has copy, drop {
game_id: ID,
player: address,
vrf_input: vector<u8>,
guess: String,
user_stake: u64,
fee_bp: u16
}
public 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:
public 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) {
// Ensure that the game exists.
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
let Game {
id,
guess_placed_epoch: _,
mut total_stake,
guess,
player,
vrf_input,
fee_bp
} = dof::remove<ID, Game>(house_data.borrow_mut(), 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, &house_data.public_key(), &vrf_input);
assert!(is_sig_valid, EInvalidBlsSig);
// Hash the beacon before taking the 1st byte.
let hashed_beacon = blake2b256(&bls_sig);
// Step 2: Determine winner.
let first_byte = 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 = total_stake.value();
let fee_amount = fee_amount(stake_amount, fee_bp);
let fees = total_stake.split(fee_amount);
house_data.borrow_fees_mut().join(fees);
// Calculate the rewards and take it from the game stake.
transfer::public_transfer(total_stake.into_coin(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).
house_data.borrow_balance_mut().join(total_stake);
HOUSE_WON_STATE
};
emit(Outcome {
game_id,
status
});
}
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.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.Now add a function that handles game disputes:
public fun dispute_and_win(house_data: &mut HouseData, game_id: ID, ctx: &mut TxContext) {
// Ensure that the game exists.
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
let Game {
id,
guess_placed_epoch,
total_stake,
guess: _,
player,
vrf_input: _,
fee_bp: _
} = dof::remove(house_data.borrow_mut(), game_id);
object::delete(id);
let caller_epoch = ctx.epoch();
let cancel_epoch = guess_placed_epoch + EPOCHS_CANCEL_AFTER;
// Ensure that minimum epochs have passed before user can cancel.
assert!(cancel_epoch <= caller_epoch, ECanNotChallengeYet);
transfer::public_transfer(total_stake.into_coin(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.
You also need a function that creates and initializes a new game:
public fun start_game(guess: String, counter: &mut Counter, coin: Coin<SUI>, house_data: &mut HouseData, ctx: &mut TxContext): ID {
let fee_bp = house_data.base_fee_in_bp();
let (game_id, new_game) = internal_start_game(guess, counter, coin, house_data, fee_bp, ctx);
dof::add(house_data.borrow_mut(), game_id, new_game);
game_id
}
The start_game function creates a new game by calling the internal internal_start_game helper function, then adds the newly created game as a dynamic object field to the HouseData object and returns the game ID for later reference.
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 {
game.total_stake.value()
}
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)
}
/// Helper function to check if a game exists.
public fun game_exists(house_data: &HouseData, game_id: ID): bool {
dof::exists_(house_data.borrow(), game_id)
}
/// Helper function to check that a game exists and return a reference to the game Object.
/// Can be used in combination with any accessor to retrieve the desired game field.
public fun borrow_game(game_id: ID, house_data: &HouseData): &Game {
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
dof::borrow(house_data.borrow(), 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) {
// Ensure guess is valid.
map_guess(guess);
let user_stake = coin.value();
// Ensure that the stake is not higher than the max stake.
assert!(user_stake <= house_data.max_stake(), EStakeTooHigh);
// Ensure that the stake is not lower than the min stake.
assert!(user_stake >= house_data.min_stake(), EStakeTooLow);
// Ensure that the house has enough balance to play for this game.
assert!(house_data.balance() >= user_stake, EInsufficientHouseBalance);
// Get the house's stake.
let mut total_stake = house_data.borrow_balance_mut().split(user_stake);
coin::put(&mut total_stake, coin);
let vrf_input = counter.get_vrf_input_and_increment();
let id = object::new(ctx);
let game_id = object::uid_to_inner(&id);
let new_game = Game {
id,
guess_placed_epoch: ctx.epoch(),
total_stake,
guess,
player: ctx.sender(),
vrf_input,
fee_bp
};
emit(NewGame {
game_id,
player: ctx.sender(),
vrf_input,
guess,
user_stake,
fee_bp
});
(game_id, new_game)
}
/// Helper function to map (H)EADS and (T)AILS to 0 and 1 respectively.
/// H = 0
/// T = 1
fun map_guess(guess: String): u8 {
let heads = HEADS;
let tails = TAILS;
assert!(guess.bytes() == heads || guess.bytes() == tails, EInvalidGuess);
if (guess.bytes() == 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.
Next, configure the Sui CLI to use testnet as the active environment, as well. If you haven't already set up a testnet environment, do so by running the following command in a terminal or console:
$ sui client new-env --alias testnet --rpc https://fullnode.testnet.sui.io:443
Run the following command to activate the testnet environment:
$ sui client switch --env testnet
The output of this command contains a packageID value that you need to save to use the package.
Partial snippet of CLI deployment output.
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x17e9468127384cfff5523940586f5617a75fac8fd93f143601983523ae9c9f31 │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::package::UpgradeCap │
│ │ Version: 75261540 │
│ │ Digest: 9ahkhuGYTNYi5GucCqmUHyBuWoV2R3rRqBu553KBPVv8 │
│ └── │
│ ┌── │
│ │ ObjectID: 0xa01d8d5ba121e7771547e749a787b4dd9ff8cc32e341c898bab5d12c46412a23 │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::package::Publisher │
│ │ Version: 75261540 │
│ │ Digest: Ba9VU2dUqg3NHkwQ4t5AKDLJQuiFZnnxvty2xREQKWm9 │
│ └── │
│ ┌── │
│ │ ObjectID: 0xfa1f6edad697afca055749fedbdee420b6cdba3edc2f7fd4927ed42f98a7e63a │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x4120b39e5d94845aa539d4b830743a7433fd8511bdcf3841f98080080f327ca8::house_data::HouseCap │
│ │ Version: 75261540 │
│ │ Digest: 5326hf6zWgdiNgr63wvwKkhUNtnTFkp82e9vfS5QHy3n │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0x0e4eb516f8899e116a26f927c8aaddae8466c8cdc3822f05c15159e3a8ff8006 │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 75261540 │
│ │ Digest: Ezmi94kWCfjRzgGTwnXehv9ipPvYQ7T6Z4wefPLRQPPY │
│ └── │
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x4120b39e5d94845aa539d4b830743a7433fd8511bdcf3841f98080080f327ca8 │
│ │ Version: 1 │
│ │ Digest: 5XbJkgx8RSccxaHoP3xinY2fMMhwKJ7qoWfp349cmZBg │
│ │ Modules: counter_nft, house_data, single_player_satoshi │
│ └── │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Save the PackageID and the ObjectID of the HouseCap object you receive in your own response to connect to your frontend.
In this case, the PackageID is 0x4120b39e5d94845aa539d4b830743a7433fd8511bdcf3841f98080080f327ca8 and the HouseCap ID is 0xfa1f6edad697afca055749fedbdee420b6cdba3edc2f7fd4927ed42f98a7e63a.
Well done. You have written and deployed the Move package. 🎉
To turn this into a complete app, you need to create a frontend.
In this final part of the app example, you build a frontend (UI) that allows end users to place bets and take profits, and lets the admin manage the house.
:::info
To skip building the frontend and test out your newly deployed package, use the provided Satoshi Coin Flip Frontend Example repository and follow the instructions in the example's README.md file
:::
<Tabs className="tabsHeadingCentered--small"> <TabItem value="prereq" label="Prerequisites"> Deploy the complete satoshi_flip Move package and understand its design.
:::tip Additional resources
@mysten/dapp, used within this project to quickly scaffold a React-based Sui app.:::
The UI of this 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:
The first step is to set up the client app. Run the following command to scaffold a new app.
$ pnpm create @mysten/dapp --template react-client-dapp
or
$ yarn create @mysten/dapp --template react-client-dapp
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.
Add the packageId value you saved from deploying your package to a new src/constants.ts file in your project:
export const PACKAGE_ID =
"0x4120b39e5d94845aa539d4b830743a7433fd8511bdcf3841f98080080f327ca8";
export const HOUSECAP_ID =
"0xfa1f6edad697afca055749fedbdee420b6cdba3edc2f7fd4927ed42f98a7e63a";
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.
:::info
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-react';
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 apps, 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.
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
const txb = new Transaction();
// 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(
{
transaction: 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 Transaction. 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:
HouseCap ID from constants.ts, which you set up in the previous section.Transaction::splitCoin for the House stake to create a new coin with a defined amount split from the Gas Coin txb.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.vector<u8>. When providing inputs that are not on-chain objects, serialize them as BCS using a combination of txb.pure and bcs imported from @mysten/sui/bcs.Now sign and execute the transaction. dApp Kit provides the signAndExecuteTransaction method on the dAppKit instance (accessed via useDAppKit()) to streamline this process. When called, it prompts the UI for you to approve, sign, and execute the transaction. The 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, useCurrentClient } from '@mysten/dapp-kit-react';
import { useQuery } from '@tanstack/react-query';
import { PACKAGE_ID } from '../../constants';
// React hook to fetch CounterNFT owned by connected wallet
// This hook demonstrates how to use `@mysten/dapp-kit-react` hooks with TanStack Query
export function useFetchCounterNft() {
const account = useCurrentAccount();
const client = useCurrentClient();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['CounterNFT', account?.address],
queryFn: () => client.core.listOwnedObjects({
owner: account!.address,
limit: 1,
type: `${PACKAGE_ID}::counter_nft::Counter`,
}),
enabled: !!account,
});
if (!account) {
return { data: [] };
}
return {
data: data && data.objects.length > 0 ? data.objects : [],
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. Use the useCurrentClient hook to get the Sui client, combined with TanStack Query's useQuery hook to query data. Different client methods require different parameters. To fetch objects owned by a known address, use the listOwnedObjects method.
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 dAppKit = useDAppKit();
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.objectId}>
<Text as="div" weight="bold">
Object ID:
</Text>
<Text as="div">{it.objectId}</Text>
<Text as="div" weight="bold">
Object Type:
</Text>
<Text as="div">{it.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 Transaction and initialize_house_data(), you can implement a similar call here.
As you might recall with Transaction, 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 Transaction();
const [counterNft] = txb.moveCall({
target: `${PACKAGE_ID}::counter_nft::mint`,
});
txb.moveCall({
target: `${PACKAGE_ID}::counter_nft::transfer_to_sender`,
arguments: [counterNft],
});
try {
const result = await dAppKit.signAndExecuteTransaction({
transaction: txb,
});
if (result.$kind === 'FailedTransaction') {
toast.error('Transaction failed');
} else {
toast.success(`Digest: ${result.Transaction?.digest}`);
refetch?.();
}
} catch (err: any) {
toast.error(err.message);
}
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.
const dAppKit = useDAppKit();
// Create new transaction
const txb = new Transaction();
// 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]?.objectId!),
stakeCoin,
txb.object(houseDataId),
],
});
try {
const result = await dAppKit.signAndExecuteTransaction({
transaction: txb,
});
if (result.$kind === 'FailedTransaction') {
toast.error('Transaction failed');
} else {
toast.success(`Digest: ${result.Transaction?.digest}`);
}
} catch (err: any) {
toast.error(err.message);
}
One final step remains: settle the game. To do this, poll for new NewGame events using the GraphQL API, then automatically call single_player_satoshi::finish_game() for each new game.
:::info
For more information on how to work with events in your dApp, see Using Events.
:::
All of this logic is in HouseFinishGame.tsx, which polls the GraphQL API for new game events:
export function HouseFinishGame() {
const dAppKit = useDAppKit();
const [housePrivHex] = useContext(HouseKeypairContext);
const [houseDataId] = useContext(HouseDataContext);
useEffect(() => {
const gqlClient = new SuiGraphQLClient({
url: 'https://graphql.mainnet.sui.io/graphql',
network: 'mainnet',
});
const queryNewGames = graphql(`
query NewGames($eventType: String!, $after: String) {
events(filter: { eventType: $eventType }, after: $after) {
nodes {
contents { json }
}
pageInfo {
hasNextPage
endCursor
}
}
}
`);
const queryCursor = graphql(`
query LatestCursor($eventType: String!) {
events(filter: { eventType: $eventType }, last: 1) {
pageInfo {
endCursor
}
}
}
`);
let cursor: string | null = null;
let cancelled = false;
async function pollForGames() {
// Capture the current end cursor so we only process new games
const initial = await gqlClient.query({
query: queryCursor,
variables: {
eventType: `${PACKAGE_ID}::single_player_satoshi::NewGame`,
},
});
cursor = initial.data?.events?.pageInfo.endCursor ?? null;
while (!cancelled) {
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
const result = await gqlClient.query({
query: queryNewGames,
variables: {
eventType: `${PACKAGE_ID}::single_player_satoshi::NewGame`,
after: cursor,
},
});
const events = result.data?.events;
if (events?.pageInfo.endCursor) {
cursor = events.pageInfo.endCursor;
}
for (const node of events?.nodes ?? []) {
const { game_id, vrf_input } = node.contents?.json as {
game_id: string;
vrf_input: number[];
};
toast.info(`NewGame started ID: ${game_id}`);
try {
const houseSignedInput = bls.sign(
new Uint8Array(vrf_input),
curveUtils.hexToBytes(housePrivHex),
);
const txb = new Transaction();
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),
],
});
const result = await dAppKit.signAndExecuteTransaction({
transaction: txb,
});
if (result.$kind === 'FailedTransaction') {
toast.error('Transaction failed');
} else {
toast.success(`Digest: ${result.Transaction?.digest}`);
}
} catch (err: any) {
console.error(err);
toast.error(err.message);
}
}
} catch (err: any) {
console.error('Polling error:', err);
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
pollForGames();
return () => {
cancelled = true;
};
}, [housePrivHex, houseDataId, dAppKit]);
return null;
}
The component uses SuiGraphQLClient to poll for NewGame events every 5 seconds, tracking the cursor to only process new events. When a new game is detected, it signs the VRF input with the BLS private key and calls single_player_satoshi::finish_game() to settle the game.
The cleanup function sets cancelled = true to stop polling when the component unmounts.
Congratulations, you completed the frontend. You can carry the lessons learned here forward when using the dApp Kit to build your next Sui project.