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.
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:
- 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.
- Bet: You choose what to bet on. Your choice becomes part of a deterministic client seed, which mixes into the outcome.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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.
End-to-end pipeline
+------------------------------------------+
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 |
+------------------------------------------+
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.
-
1Grab the on-chain commitment
Look up your bet's on-chain
BetOrdervia Solscan or the RPC. Pull thecommitment_hashfield (32 bytes). This is what the settler promised, recorded before you bet. If your bet has been settled, the BetOrder has been closed — use theBetPlacedevent that loggedcommitment_hashat placement time. -
2Grab the revealed server seed
At settlement, the
BetSettledevent is emitted with the rawserver_seedas a[u8; 32]. Decode it from base64 or hex depending on your tool. The game's own settled page also shows it. -
3Check 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. -
4Reconstruct 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. -
5Compute 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. -
6Read 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)
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)
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");
}
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).
- 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
- 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)
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).
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 |