Double your BUX! Play Now →
Games · Provably fair

Provably fair, byte by byte.

Every coin flip on Blockster is a pure function of three inputs — a server seed we commit to on-chain before you bet, a client seed you can reconstruct from your bet parameters, and a strictly-increasing nonce. Here is exactly how it works, and how to verify a game yourself.

01

The intuition

A fair coin flip is one where nobody can influence the outcome after bets are placed, and anyone can verify the result after the fact. The challenge in online gambling is that somebody has to generate the randomness — and they can't be trusted to report it honestly unless they've somehow committed to it in advance.

Our scheme is the standard "commit-reveal" construction:

  1. Commit: Before you bet, we generate a random server seed, compute its SHA-256 hash, and publish the hash on-chain. This locks in the seed — we cannot change it later without breaking the hash.
  2. Bet: You choose what to bet on. Your choice becomes part of a deterministic client seed, which mixes into the outcome.
  3. Reveal: After the bet is placed, we submit the raw seed. The program on-chain recomputes the hash and refuses to settle unless it matches what we committed.
  4. Verify: The seed is now public. You can take the server seed, your client seed, the nonce, and the same hash function we use, and recompute the outcome yourself.
02

The three seeds

Server seed

Generated by the Elixir backend via :crypto.strong_rand_bytes(32), which reads from the OS cryptographic RNG. 32 bytes of uniform randomness — roughly 256 bits of entropy, which is more than anyone in the history of computation has meaningfully exhausted.

Stored as a 64-character lowercase hex string in Mnesia. The raw bytes are what we commit to on-chain; the hex is a serialization-friendly representation for the UI.

coin_flip_game.ex :: init_game_with_nonce
elixir
raw_seed = :crypto.strong_rand_bytes(32)
server_seed = Base.encode16(raw_seed, case: :lower) # 64-char hex

# commitment hashed over RAW bytes, not hex string — this matters
commitment_hash_bytes = :crypto.hash(:sha256, raw_seed)
commitment_hash = Base.encode16(commitment_hash_bytes, case: :lower)

Client seed

A deterministic 32-byte value derived from only the parameters you chose for your bet. No timestamps, no counters, no server state — so given the same bet parameters, you always produce the same client seed.

coin_flip_game.ex :: generate_client_seed_from_bet
elixir
def generate_client_seed_from_bet(user_id, bet_amount, vault_type, difficulty, predictions) do
  predictions_str = predictions |> Enum.map(&Atom.to_string/1) |> Enum.join(",")
  token_str = if is_atom(vault_type), do: Atom.to_string(vault_type), else: to_string(vault_type)
  input = "#{user_id}:#{bet_amount}:#{token_str}:#{difficulty}:#{predictions_str}"
  :crypto.hash(:sha256, input)
end

Concretely, if user 42 bets 1.0 SOL at difficulty +2 (Win All, 2 flips) with predictions [:heads, :tails], the input string is "42:1.0:sol:2:heads,tails". That string is SHA-256'd to produce 32 bytes. If you know the user's id and the bet parameters (both public once the bet is placed), you can reconstruct the client seed without any server cooperation.

Nonce

An integer starting at 0 that increments on every bet the player places. Stored on-chain in player_state.nonce. It makes each (server_seed, client_seed) pair produce a different outcome even if the rest of the inputs happen to repeat, and it provides hard replay protection: a bet order's PDA is seeded with the nonce, so the same nonce cannot be used twice.

03

The combined seed

All three inputs are concatenated with colons and hashed once more. The result is 32 bytes of pseudo-random data that drives the game.

coin_flip_game.ex :: calculate_game_result
elixir
client_seed_hex = Base.encode16(client_seed, case: :lower)
combined_input  = "#{server_seed}:#{client_seed_hex}:#{nonce}"
combined_seed   = :crypto.hash(:sha256, combined_input)
# combined_seed :: binary, 32 bytes

Both seeds are expressed as lowercase hex before concatenation. The nonce is a plain decimal integer (no padding). Given identical inputs, any SHA-256 implementation in any language will produce identical output — this is why the scheme is cross-verifiable.

04

From bytes to flips

The combined seed has 32 bytes. We use the first N of them — where N is the number of flips in the round — and turn each byte into heads or tails by a single threshold.

coin_flip_game.ex :: generate_flip_results
elixir
def generate_flip_results(combined_seed, num_flips) do
  for i <- 0..(num_flips - 1) do
    byte = :binary.at(combined_seed, i)
    if byte < 128, do: :heads, else: :tails
  end
end

Each byte takes values 0–255 uniformly. Bytes 0–127 (half of them) produce heads. Bytes 128–255 (the other half) produce tails. Exactly 50/50, unbiased. No modular-arithmetic slop, no off-by-one that skews the distribution by a fraction of a percent.

05

End-to-end pipeline

From RNG to payout
txt
                   +------------------------------------------+
  backend RNG     |  raw_seed  = strong_rand_bytes(32)        |
                  |  server_seed = hex(raw_seed)              |
                   +---------------------+--------------------+
                                         |
                                SHA256(raw_seed)
                                         |
                                         v
                   +------------------------------------------+
  submit_commitment|  commitment_hash pushed on-chain for     |
                   |  (player_pubkey, nonce)                  |
                   +------------------------------------------+
                                         |
                  (user decides bet params: amount,
                   vault, difficulty, predictions)
                                         |
                                         v
                   +------------------------------------------+
  client seed     |  client_seed_bytes = SHA256(              |
                  |    "uid:amt:tok:diff:preds"               |
                  |  )                                        |
                   +---------------------+--------------------+
                                         |
                                         v
                   +------------------------------------------+
  combined seed   |  combined = SHA256(                       |
                  |    server_seed_hex + ":" +                |
                  |    client_seed_hex + ":" +                |
                  |    to_string(nonce)                       |
                  |  )                                        |
                   +---------------------+--------------------+
                                         |
                                         v
                   +------------------------------------------+
  flip results    |  flips = first N bytes of combined        |
                  |          <128 heads, else tails           |
                   +---------------------+--------------------+
                                         |
                                         v
                   +------------------------------------------+
  win decision    |  Win All: all(pred == result)             |
                  |  Win One: any(pred == result)             |
                   +---------------------+--------------------+
                                         |
                                         v
                   +------------------------------------------+
  settle_bet      |  reveal server_seed on-chain              |
                  |  SHA256(server_seed) == commitment? ✓     |
                  |  payout = won ? amount * mult / 10000 : 0 |
                   +------------------------------------------+
06

Verify a game yourself

Every settled game record on Blockster exposes five pieces of data: server seed, client seed (or the bet params used to build it), nonce, number of flips, and the resulting flips. With those five, you can verify the outcome on your own machine in about ten lines of code.

  1. 1
    Grab the on-chain commitment

    Look up your bet's on-chain BetOrder via Solscan or the RPC. Pull the commitment_hash field (32 bytes). This is what the settler promised, recorded before you bet. If your bet has been settled, the BetOrder has been closed — use the BetPlaced event that logged commitment_hash at placement time.

  2. 2
    Grab the revealed server seed

    At settlement, the BetSettled event is emitted with the raw server_seed as a [u8; 32]. Decode it from base64 or hex depending on your tool. The game's own settled page also shows it.

  3. 3
    Check the commitment

    Compute sha256(server_seed_bytes). It must equal the commitment_hash exactly. If it doesn't, the chain rejected the settlement — it never happened.

  4. 4
    Reconstruct the client seed

    Concatenate your user ID, bet amount (as a decimal string), vault type, difficulty, and predictions (comma-separated) with colons: "42:1.0:sol:1:heads,tails". SHA-256 that string. That's your client_seed — 32 bytes.

  5. 5
    Compute the combined seed

    Turn both seeds into lowercase hex. Concatenate server_hex + ":" + client_hex + ":" + str(nonce). SHA-256 the whole string to get 32 bytes.

  6. 6
    Read the flips

    Take the first N bytes (where N is the number of flips in your round). For each, heads if <128, tails otherwise. These must match what the UI showed you.

Reference implementation (Python)

verify.py
python
import hashlib

def verify_flip(
    server_seed_hex: str,     # from BetSettled event (64 hex chars)
    commitment_hex: str,      # from BetPlaced event  (64 hex chars)
    user_id: int,             # your Blockster user_id
    bet_amount: float,        # e.g. 1.0
    vault_type: str,          # "sol" or "bux"
    difficulty: int,          # e.g. 1
    predictions: list,        # ["heads", "tails"]
    nonce: int,
) -> list:
    server_bytes = bytes.fromhex(server_seed_hex)

    # 1. commitment matches
    assert hashlib.sha256(server_bytes).hexdigest() == commitment_hex

    # 2. rebuild client seed
    client_input = f"{user_id}:{bet_amount}:{vault_type}:{difficulty}:{','.join(predictions)}"
    client_bytes = hashlib.sha256(client_input.encode()).digest()
    client_hex   = client_bytes.hex()

    # 3. combined seed
    combined_input = f"{server_seed_hex}:{client_hex}:{nonce}"
    combined = hashlib.sha256(combined_input.encode()).digest()

    # 4. derive flips
    flips = ["heads" if combined[i] < 128 else "tails" for i in range(len(predictions))]
    return flips

Reference implementation (Node.js)

verify.js
javascript
const { createHash } = require("crypto");

function sha256Hex(input) {
  return createHash("sha256").update(input).digest("hex");
}
function sha256Bytes(input) {
  return createHash("sha256").update(input).digest();
}

function verify({ serverSeedHex, commitmentHex, userId, amount, vault, difficulty, predictions, nonce }) {
  const serverBytes = Buffer.from(serverSeedHex, "hex");
  if (sha256Hex(serverBytes) !== commitmentHex) throw new Error("commitment mismatch");

  const clientInput = `${userId}:${amount}:${vault}:${difficulty}:${predictions.join(",")}`;
  const clientHex = sha256Hex(clientInput);
  const combined = sha256Bytes(`${serverSeedHex}:${clientHex}:${nonce}`);

  return predictions.map((_, i) => combined[i] < 128 ? "heads" : "tails");
}
07

Worked example

Let's walk through a real example with concrete numbers. Suppose player 42 bets 0.1 SOL on difficulty +2 (Win All of 2 flips, 3.96×), predicting heads then tails, at nonce 7. The server happens to pick seed 0xa1b2...c3d4 (32 bytes).

Inputs
server_seed (hex)
a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f506172839405162738495a (example)
commitment = sha256(server_bytes)
45a9... (the SHA-256 of the 32 bytes above)
user_id
42
bet_amount
0.1
vault_type
sol
difficulty
2 (Win All, 2 flips)
predictions
[heads, tails]
nonce
7
Derivations
client_seed input string
"42:0.1:sol:2:heads,tails"
client_seed (sha256 bytes, hex)
5e... (sha256 of the string above)
combined input string
"a1b2...c3d4 : 5e... : 7" (as one string, colons no spaces)
combined_seed (sha256 bytes)
e7f9... (the 32-byte hash)
first byte
0xe7 = 231 → tails
second byte
0xf9 = 249 → tails
predictions
[heads, tails]
result (Win All)
tails vs heads: wrong · tails vs tails: right → LOSS (needed both)
08

What this scheme defends against

Defence: server picks the seed after you bet

Impossible. submit_commitment must land before place_bet_*, and the place_bet instruction explicitly rejects if there is no pending commitment. The program also copies commitment_hash into the BetOrder at placement, so the commitment is frozen into the bet.

Defence: server changes the seed at settlement

Impossible. At settle, the program recomputes SHA256(server_seed) and reverts with InvalidServerSeed if it doesn't match. The only seed that will settle is the one we committed to.

Defence: replay or reuse of a seed

Impossible. Nonce is in every bet's PDA seeds, and the program increments the nonce monotonically in PlayerState. Reusing a nonce would attempt to initialise an already-existing PDA and fail. Reusing a seed at a different nonce produces a different combined seed and thus different flips, so no information leaks.

Defence: client manipulates their own client seed

The client seed is generated from the bet parameters themselves. Changing them changes the bet, not just the seed, so the new bet is a different bet — there's no "tweak" that only affects randomness. And because the server seed is already committed, you can't shop for a client seed that produces a winning combined seed: the space of possible client seeds you can reach is exactly the space of possible bets.

Defence: server refuses to reveal a losing seed

The reclaim path covers this. If 120 seconds elapse without a settlement, you can sign reclaim_expired yourself and take your wager back. A server that selectively withholds seeds is punishable by watching its players leave, since reclaiming a bet marks it as never having happened (no stats updated, no house profit booked).

09

Where each piece lives

Piece Generated by Stored where Visible when
server_seed Elixir backend RNG Mnesia :coin_flip_games.server_seed After settlement
commitment_hash SHA256(server_seed) on-chain PlayerState.pending_commitment, then BetOrder.commitment_hash Before bet is placed
client_seed Deterministic from bet params Never stored (rebuild on demand) Always (from public bet params)
nonce on-chain counter on-chain PlayerState.nonce; copied to BetOrder.nonce Always
combined_seed SHA256(server:client:nonce) Never stored (rebuild on demand) After server_seed reveal
flip_results First N bytes of combined_seed Derived After settlement