Coin Flip
This example walks you through building a coin flip dApp, covering the full end-to-end flow of building your Sui Move module and connecting it to your React Sui dApp. This coin flip dApp 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:
- Smart Contracts: The Move code that sets up the coin flip logic.
- Frontend: A UI that enables the players to place bets and take profits, and the admin to manage the house.
Source code locations for the smart contracts and frontend:
What the guide teaches
- Shared objects: The guide teaches you how to use shared objects, in this case to create a globally accessible
HouseData
object. - One-time witnesses: The guide teaches you how to use one-time witnesses to ensure only a single instance of the
HouseData
object ever exists. - Asserts: The guide teaches you how to use asserts to abort functions due to certain conditions not being met.
- Address-owned objects: The guide teaches you how to use address-owned objects when necessary.
- Events: The guide teaches you how to emit events in your contracts, which can be used to track off chain.
- Storage rebates: The guide shows you best practices regarding storage fee rebates.
- MEV attack protection: The guide introduces you to MEV attacks, how to make your contracts MEV-resistant, and the trade-offs between protection and user experience.
What you need
Before getting started, make sure you have:
Smart contracts
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.
To follow along with this guide, set your new Move package to satoshi_flip
.
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.
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:
- 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.
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());
}
- 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() > 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 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 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.
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::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
});
}
- 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) {
// 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.
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
}
}
}
Finished package
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, if you haven't already. To do so, in a terminal or console at the root directory of the project enter sui client
. If you receive the following response, complete the remaining instructions:
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]?
Enter y
to proceed. You receive the following response:
Sui Full node server URL (Defaults to Sui Devnet if not specified) :
Leave this blank (press Enter). You receive the following response:
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.
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
Before being able to publish your package to Testnet, you need Testnet SUI tokens. To get some, join the Sui Discord, complete the verification steps, enter the #testnet-faucet
channel and type !faucet <WALLET ADDRESS>
. For other ways to get SUI in your Testnet account, see Get SUI Tokens.
Now that you have an account with some Testnet 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 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 │