Introduction
Trust is the fundamental commodity of the gambling industry. A player sitting at a slot machine in a physical casino can watch the reels spin, watch the symbols land, and trust — at least in principle — that the machine is not cheating them. The randomness is visible, tactile, and regulated by physical mechanics they can observe.
An online slot is a different proposition entirely. The player sees a beautifully animated interface on their screen. Behind it, somewhere on a server they cannot access, a computation happens. A number is generated. Symbols are determined. A win or loss is calculated. The player is shown the result and expected to believe it.
In traditional regulated online gambling, this trust is established through a chain of institutional actors: the certification laboratory that tested the RNG, the regulator that issued the licence, the operator that holds the licence and is legally accountable. The player trusts the outcome because they trust the institutions.
Provably Fair is a fundamentally different model. Instead of asking the player to trust institutions, it gives the player the cryptographic tools to verify, independently and after the fact, that every outcome they received was determined before they placed their bet and was not manipulated. Trust is replaced by mathematical proof.
This article is the complete technical implementation guide for Provably Fair in the context of slot games. We build the full system from first principles: the cryptographic primitives, the commit-reveal protocol, the HMAC-SHA256 derivation of reel stops from combined seeds, the commitment publication and session reveal mechanisms, the client-side verification code, and the architectural integration with the state machine and session management systems from previous articles. We also examine the relationship between Provably Fair and traditional regulatory certification — they are complementary, not competing, and understanding how they interact is essential for deploying Provably Fair in regulated markets.
Part I. The Problem Provably Fair Solves
1.1 The Fundamental Trust Problem
Consider the simplest possible online slot outcome: the server generates a number between 1 and 32 to determine which symbol lands on a reel. From the player's perspective:
What the player sees: Diamond lands on reel 1
What the player knows: The server said Diamond landed
What the player cannot: Verify that the server's RNG actually produced
the stop position that places Diamond in that rowThe player has no way to verify that the number produced by the server's RNG genuinely determined the symbol they saw. The server could, in principle, have decided the outcome first (no win, always) and then generated a "random" number that justifies it. This attack is called outcome post-selection — and it is mathematically impossible to detect through observation of results alone.
Provably Fair closes this gap with a cryptographic commitment scheme that makes post-selection impossible even if the server wanted to cheat:
BEFORE the spin: Server commits to its secret seed.
Player cannot predict the outcome from the commitment.
DURING the spin: Player provides their own seed.
Outcome = f(ServerSeed, ClientSeed, SpinNonce)
Neither party can control the outcome alone.
AFTER the session: Server reveals the secret seed.
Player verifies: Hash(revealed seed) = commitment.
Player recomputes: f(revealed seed, clientSeed, nonce)
= announced outcome ✓Post-selection is impossible because the server committed to its seed before receiving the player's seed. Changing the server seed would change the commitment hash — a detectable change. The player's seed ensures the server cannot predict the outcome either, preventing any forward bias.
1.2 What Provably Fair Does Not Solve
Intellectual honesty requires stating what Provably Fair does not protect against:
It does not guarantee a specific RTP. A Provably Fair system with biased reel strips still returns less than its pay table implies. Provably Fair proves the outcome was random — not that the game's mathematical model is fair.
It does not prevent front-running. If the server could observe the client seed before generating its own server seed (which a correct implementation prevents through proper timing), it could bias the outcome.
It does not protect against client seed guessing. If the client seed is predictable (e.g., derived from a weak JavaScript random), the server could predict it and adjust its server seed accordingly. The client seed must be cryptographically random.
It does not replace regulatory certification in licensed markets. Regulators care about provable RTP, responsible gambling features, and audit logs — not just cryptographic fairness proofs. Provably Fair and certification are complementary.
1.3 Who Uses Provably Fair
Provably Fair originated in the crypto gambling space, where:
Operators frequently lack traditional gaming licences
Players have no regulatory recourse in case of disputes
The technical sophistication of the player base supports cryptographic verification
In licensed markets, Provably Fair is increasingly adopted as a transparency layer on top of standard certification — providing players with self-service verification while maintaining full regulatory compliance.
Part II. Cryptographic Foundations
2.1 Hash Functions and Commitment Schemes
The entire Provably Fair mechanism rests on the security properties of cryptographic hash functions. Specifically, SHA-256.
SHA-256 properties relevant to Provably Fair:
Pre-image resistance:
Given h = SHA256(x), it is computationally infeasible to find x.
This means: given the commitment (hash of server seed),
the player cannot discover the server seed before the session ends.
The server's secret is safe until revealed.
Second pre-image resistance:
Given x and h = SHA256(x), it is computationally infeasible to find
x' ≠ x such that SHA256(x') = h.
This means: the server cannot change its server seed after publishing
the commitment while maintaining the same commitment hash.
The server is bound to the committed seed.
Collision resistance:
It is computationally infeasible to find any x, x' where
SHA256(x) = SHA256(x').
This means: the server cannot prepare two different server seeds that
produce the same commitment, then switch between them based on the
player's seed.2.2 HMAC and Keyed Hashing
The outcome derivation uses HMAC-SHA256 (Hash-based Message Authentication Code) rather than plain SHA-256. HMAC adds a key to the hash computation:
HMAC-SHA256(key, message) = SHA256((key ⊕ opad) || SHA256((key ⊕ ipad) || message))Where opad and ipad are fixed padding constants. The critical property: knowing HMAC-SHA256(k, m) for many messages m reveals nothing about k (assuming SHA-256 is secure).
In the Provably Fair context:
key = Server Seed (kept secret until session end)
message = Client Seed + ":" + Nonce + ":" + ReelIndex
output = HMAC-SHA256(ServerSeed, ClientSeed:Nonce:ReelIndex)This construction means:
The server cannot predict the output before knowing the client seed (depends on client seed)
The player cannot compute the output before the server seed is revealed (depends on server seed)
The output is deterministic: the same inputs always produce the same output
2.3 Deriving a Reel Stop from Hash Output
HMAC-SHA256 produces 32 bytes (256 bits) of output. We need an integer in [0, reelSize). The derivation must be:
Unbiased: every stop position has equal probability
Deterministic: same inputs → same output
Efficient: minimal additional randomness calls needed
The standard approach uses rejection sampling — Lemire's method in efficient form:
/// <summary>
/// Derives an unbiased integer in [0, range) from a 32-byte hash output.
/// Uses rejection sampling to eliminate modular bias.
/// For typical reel sizes (≤ 64), rejection is extremely rare.
/// </summary>
public static int DeriveUnbiasedInt(byte[] hashOutput, int range)
{
if (range <= 0) throw new ArgumentOutOfRangeException(nameof(range));
if (range == 1) return 0;
// We'll work through the hash output 4 bytes at a time.
// If we exhaust 32 bytes without a valid value, we extend with counter.
for (int offset = 0; offset + 4 <= hashOutput.Length; offset += 4)
{
uint value = BitConverter.ToUInt32(hashOutput, offset);
uint threshold = uint.MaxValue % (uint)range;
// Reject values below threshold to eliminate bias
// (extremely rare: ~1/reelSize probability)
if (value < threshold) continue;
return (int)(value % (uint)range);
}
// Hash exhausted (extremely unlikely for small ranges)
// Use last 4 bytes with counter extension
uint fallback = BitConverter.ToUInt32(hashOutput, 28);
return (int)(fallback % (uint)range);
}For a reel of size 32: threshold = 2^32 % 32 = 0. No rejection is needed — every value of the 4-byte uint maps cleanly to a reel stop via modulo 32.
For a reel of size 30: threshold = 2^32 % 30 = 16. Values in [0, 16) are rejected. Expected rejections: 16 / 2^32 ≈ 0.0000037% — essentially never in practice.
Part III. The Protocol Design
3.1 The Complete Provably Fair Flow
┌──────────────────────────────────────────────────────────────────┐
│ PROVABLY FAIR SESSION LIFECYCLE │
├──────────────────────────────────────────────────────────────────┤
│ │
│ SESSION START │
│ ───────────────────────────────────────────────────────────── │
│ 1. Server generates ServerSeed (32 cryptographically random │
│ bytes from OS CSPRNG) │
│ 2. Server computes Commitment = SHA256(ServerSeed) │
│ 3. Server STORES ServerSeed (secret, not logged outside HSM) │
│ 4. Server PUBLISHES Commitment to player (in session init) │
│ → Player can verify this commitment will be kept │
│ 5. Server generates or accepts ClientSeed from player │
│ → Client-side: crypto.randomUUID() or similar CSPRNG │
│ 6. SpinNonce initialised to 1 for this session │
│ │
│ PER SPIN │
│ ───────────────────────────────────────────────────────────── │
│ 7. For each reel i (0..4): │
│ message_i = UTF8(ClientSeed + ":" + Nonce + ":" + i) │
│ hmac_i = HMAC-SHA256(key=ServerSeed, msg=message_i) │
│ stop_i = DeriveUnbiasedInt(hmac_i, reelSize_i) │
│ 8. Spin outcome determined from {stop_0..stop_4} │
│ 9. SpinNonce++ │
│ │
│ PLAYER CAN CHANGE CLIENT SEED (anytime between spins) │
│ ───────────────────────────────────────────────────────────── │
│ 10. Player submits new ClientSeed │
│ Server acknowledges; new seed takes effect next spin │
│ Previous spins remain verifiable with old client seed │
│ │
│ SESSION END (server seed rotation) │
│ ───────────────────────────────────────────────────────────── │
│ 11. Player decides to end session / change server seed │
│ 12. Server publishes (reveals) ServerSeed │
│ 13. Player verifies: SHA256(ServerSeed) == published Commitment │
│ 14. New session: new ServerSeed, new Commitment, Nonce resets │
│ │
│ INDEPENDENT VERIFICATION │
│ ───────────────────────────────────────────────────────────── │
│ 15. Player computes: │
│ hmac_i = HMAC-SHA256(ServerSeed, ClientSeed:Nonce:i) │
│ stop_i = DeriveUnbiasedInt(hmac_i, reelSize_i) │
│ Verifies this matches the announced spin outcome │
│ │
└──────────────────────────────────────────────────────────────────┘3.2 The Message Format
The HMAC message format must be precisely specified and documented. Every field, every separator, every encoding rule must be unambiguous — the player's verification code must be able to reproduce it exactly.
Message format for reel i, spin nonce n, client seed cs:
"{cs}:{n}:{i}"
Where:
cs = client seed (hex string, lowercase, 64 characters for 32-byte seed)
n = spin nonce (decimal integer, no leading zeros, 1-indexed)
i = reel index (decimal integer, 0-indexed)
Examples:
ClientSeed = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
SpinNonce = 42
ReelIndex = 0
Message = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1:42:0"
ReelIndex = 1
Message = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1:42:1"The choice of ":" as separator is conventional. What matters is that it is documented, consistent, and impossible to collide (the client seed hex string contains only hex characters, never ":").
Part IV. Full C# Server Implementation
4.1 The Provably Fair Session
/// <summary>
/// A Provably Fair session manages the cryptographic state for one
/// server seed period. A new session begins when the server seed
/// is rotated (either by player request or by the system).
///
/// The server seed is never exposed while the session is active.
/// Only the SHA-256 commitment is shared with the player.
/// </summary>
public sealed class ProvablyFairSession
{
// ── Private: never exposed during session ──────────────────────
private readonly byte[] _serverSeedBytes;
// ── Public: shared with player at session start ────────────────
public string SessionId { get; }
public string Commitment { get; } // SHA256(ServerSeed), hex lowercase
public string ServerSeedHash => Commitment; // Alias for clarity
// ── Mutable: changes per spin or by player action ─────────────
public string ClientSeed { get; private set; }
public long SpinNonce { get; private set; }
// ── Revealed after session ends ───────────────────────────────
public string? RevealedServerSeed { get; private set; }
public bool IsRevealed => RevealedServerSeed is not null;
// ── Audit metadata ────────────────────────────────────────────
public DateTime CreatedAt { get; }
public DateTime? RevealedAt { get; private set; }
public long TotalSpins => SpinNonce - 1;
private ProvablyFairSession(
string sessionId,
byte[] serverSeedBytes,
string commitment,
string clientSeed)
{
SessionId = sessionId;
_serverSeedBytes = serverSeedBytes;
Commitment = commitment;
ClientSeed = clientSeed;
SpinNonce = 1;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// Creates a new Provably Fair session.
/// Generates a cryptographically secure server seed from the OS CSPRNG.
/// Computes and stores the SHA-256 commitment.
/// </summary>
public static ProvablyFairSession Create(
string sessionId,
string? clientSeed = null)
{
// Server seed: 32 bytes from OS CSPRNG (BCryptGenRandom / /dev/urandom)
byte[] serverSeedBytes = new byte[32];
RandomNumberGenerator.Fill(serverSeedBytes);
// Commitment: SHA-256 of the raw seed bytes
byte[] hash = SHA256.HashData(serverSeedBytes);
string commitment = Convert.ToHexString(hash).ToLowerInvariant();
// Client seed: provided by player or generated server-side
// When server-generated, it can be changed by the player before first spin
string cs = clientSeed
?? Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
return new ProvablyFairSession(sessionId, serverSeedBytes, commitment, cs);
}
/// <summary>
/// Generates reel stop positions for a single spin.
/// Uses HMAC-SHA256 with the server seed as key.
/// Increments the spin nonce after generation.
/// </summary>
public int[] GenerateReelStops(IReadOnlyList<int> reelSizes)
{
if (IsRevealed)
throw new InvalidOperationException(
"Cannot generate stops after server seed has been revealed.");
var stops = new int[reelSizes.Count];
for (int reelIndex = 0; reelIndex < reelSizes.Count; reelIndex++)
{
stops[reelIndex] = DeriveStop(
_serverSeedBytes,
ClientSeed,
SpinNonce,
reelIndex,
reelSizes[reelIndex]);
}
SpinNonce++;
return stops;
}
/// <summary>
/// Updates the client seed. Takes effect on the next spin.
/// The new seed is accepted regardless of its source —
/// it is the player's contribution to the outcome derivation.
/// </summary>
public void UpdateClientSeed(string newClientSeed)
{
if (string.IsNullOrWhiteSpace(newClientSeed))
throw new ArgumentException("Client seed cannot be empty.");
// Validate: must be a valid hex string of reasonable length
if (newClientSeed.Length < 16 || newClientSeed.Length > 128)
throw new ArgumentException(
"Client seed must be between 16 and 128 characters.");
if (!newClientSeed.All(c => "0123456789abcdefABCDEF".Contains(c)))
throw new ArgumentException(
"Client seed must be a hexadecimal string.");
ClientSeed = newClientSeed.ToLowerInvariant();
}
/// <summary>
/// Reveals the server seed at session end.
/// After this call, no more stops can be generated.
/// The player can verify: SHA256(RevealedServerSeed) == Commitment.
/// </summary>
public string RevealServerSeed()
{
if (IsRevealed)
return RevealedServerSeed!;
RevealedServerSeed = Convert.ToHexString(_serverSeedBytes).ToLowerInvariant();
RevealedAt = DateTime.UtcNow;
return RevealedServerSeed;
}
/// <summary>
/// Static verification: confirms a revealed server seed matches a commitment.
/// Called by the player (or on their behalf) after session end.
/// </summary>
public static bool VerifyCommitment(string revealedSeedHex, string commitment)
{
try
{
byte[] seedBytes = Convert.FromHexString(revealedSeedHex);
byte[] hash = SHA256.HashData(seedBytes);
string computedHash = Convert.ToHexString(hash).ToLowerInvariant();
return computedHash.Equals(commitment, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false; // Invalid hex string
}
}
/// <summary>
/// Static derivation: reproduces a specific spin's reel stops.
/// Can be called by the player after session reveal for independent verification.
/// The algorithm is public and documented — any implementation produces the same result.
/// </summary>
public static int[] DeriveStopsForSpin(
string revealedServerSeedHex,
string clientSeed,
long spinNonce,
IReadOnlyList<int> reelSizes)
{
byte[] serverSeedBytes = Convert.FromHexString(revealedServerSeedHex);
var stops = new int[reelSizes.Count];
for (int reelIndex = 0; reelIndex < reelSizes.Count; reelIndex++)
{
stops[reelIndex] = DeriveStop(
serverSeedBytes, clientSeed, spinNonce,
reelIndex, reelSizes[reelIndex]);
}
return stops;
}
// ── Core derivation function ───────────────────────────────────
private static int DeriveStop(
byte[] serverSeedBytes,
string clientSeed,
long spinNonce,
int reelIndex,
int reelSize)
{
// Message: "{clientSeed}:{spinNonce}:{reelIndex}"
string message = $"{clientSeed}:{spinNonce}:{reelIndex}";
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
// HMAC-SHA256(key=ServerSeed, message)
byte[] hmacOutput = HMACSHA256.HashData(serverSeedBytes, messageBytes);
// Derive unbiased integer in [0, reelSize)
return DeriveUnbiasedInt(hmacOutput, reelSize);
}
private static int DeriveUnbiasedInt(byte[] hashBytes, int range)
{
// Try each 4-byte slice with rejection sampling
for (int offset = 0; offset + 4 <= hashBytes.Length; offset += 4)
{
uint value = BinaryPrimitives.ReadUInt32LittleEndian(
hashBytes.AsSpan(offset, 4));
uint threshold = (uint)(-(int)range % range);
// threshold = (2^32) mod range = first value where modulo is unbiased
if (value >= threshold)
return (int)(value % (uint)range);
}
// Fallback for pathological cases (essentially never occurs for range ≤ 2^30)
uint fallback = BinaryPrimitives.ReadUInt32LittleEndian(hashBytes.AsSpan(0, 4));
return (int)(fallback % (uint)range);
}
}4.2 The Provably Fair Repository
/// <summary>
/// Persists Provably Fair session state.
/// The server seed bytes must be stored encrypted at rest —
/// they are the cryptographic secret that guarantees fairness.
/// </summary>
public interface IProvablyFairRepository
{
Task SaveSessionAsync(ProvablyFairRecord record, CancellationToken ct = default);
Task<ProvablyFairRecord?> LoadBySessionIdAsync(string sessionId, CancellationToken ct = default);
Task UpdateNonceAsync(string sessionId, long newNonce, CancellationToken ct = default);
Task UpdateClientSeedAsync(string sessionId, string newClientSeed, long atNonce, CancellationToken ct = default);
Task RevealAsync(string sessionId, string revealedSeedHex, CancellationToken ct = default);
Task<List<SpinVerificationRecord>> GetSpinsForVerificationAsync(
string sessionId, long? fromNonce, long? toNonce, CancellationToken ct = default);
}
/// <summary>
/// The persisted record for a Provably Fair session.
/// ServerSeedEncrypted must use AES-256-GCM with a KMS-managed key.
/// </summary>
public sealed record ProvablyFairRecord(
string SessionId,
string GameSessionId, // Links to main session record
string ServerSeedEncrypted, // AES-256-GCM encrypted; never stored plaintext
string Commitment, // SHA256(ServerSeed), public
string ClientSeed, // Current active client seed
long CurrentNonce,
DateTime CreatedAt,
string? RevealedServerSeed, // Populated after session end
DateTime? RevealedAt
);
/// <summary>
/// Record sufficient for independent verification of a single spin.
/// Stored per-spin for verifiability.
/// </summary>
public sealed record SpinVerificationRecord(
string SessionId,
long SpinNonce,
string ClientSeedAtSpin, // Client seed active at this spin's execution
string Commitment, // Server seed commitment at time of spin
int[] DeclaredStops, // Stops declared in the spin response
int[] ReelSizes, // Reel configuration at time of spin
string GameVersion, // Enables future re-certification audits
DateTime SpinTimestamp
);4.3 Integrating with the Spin Use Case
/// <summary>
/// Extension of the SpinUseCase that derives reel stops from
/// a Provably Fair session instead of calling the OS CSPRNG directly.
///
/// The two approaches are architecturally compatible:
/// - Standard RNG: stops = CSPRNG.NextReelStops(reelSizes)
/// - Provably Fair: stops = pfSession.GenerateReelStops(reelSizes)
///
/// Both produce statistically equivalent, unpredictable stop positions.
/// The Provably Fair version additionally allows post-hoc verification.
/// </summary>
public sealed class ProvablyFairSpinEngine : ISpinEngine
{
private readonly IProvablyFairRepository _pfRepo;
private readonly GameConfig _config;
public async Task<SpinOutcome> ExecuteAsync(
string gameSessionId,
decimal betAmount,
CancellationToken ct = default)
{
// Load the active Provably Fair session
var pfRecord = await _pfRepo.LoadBySessionIdAsync(gameSessionId, ct)
?? throw new ProvablyFairSessionNotFoundException(gameSessionId);
// Reconstruct session from record (server seed decrypted from KMS)
var pfSession = await RehydrateSessionAsync(pfRecord, ct);
// Derive reel stops from combined seeds
int[] stops = pfSession.GenerateReelStops(_config.ReelSizes);
// Persist updated nonce immediately, before any other operation
// This ensures the nonce is consistent with the generated stops
// even if the server crashes before the spin response is sent
await _pfRepo.UpdateNonceAsync(
gameSessionId, pfSession.SpinNonce, ct);
// Store per-spin verification record
await _pfRepo.SaveSpinVerificationAsync(new SpinVerificationRecord(
SessionId: gameSessionId,
SpinNonce: pfSession.SpinNonce - 1, // Just incremented
ClientSeedAtSpin: pfSession.ClientSeed,
Commitment: pfSession.Commitment,
DeclaredStops: stops,
ReelSizes: _config.ReelSizes,
GameVersion: _config.Version,
SpinTimestamp: DateTime.UtcNow
), ct);
// Build visible grid and evaluate wins (same as standard engine)
int[,] grid = BuildGrid(stops);
var wins = EvaluateAllLines(grid, betAmount / _config.LinesCount);
int scatters = CountScatters(grid);
return new SpinOutcome(
ReelStops: stops,
VisibleGrid: grid,
Wins: wins,
TotalWin: wins.Sum(w => w.Amount),
ScatterCount: scatters,
BonusTriggered: scatters >= _config.BonusTriggerScatterCount,
FreeSpinsAwarded: scatters >= _config.BonusTriggerScatterCount
? _config.GetFreeSpinsForScatterCount(scatters) : 0
);
}
private async Task<ProvablyFairSession> RehydrateSessionAsync(
ProvablyFairRecord record, CancellationToken ct)
{
// Decrypt server seed from KMS-managed key
byte[] serverSeedBytes = await _keyManagement
.DecryptAsync(record.ServerSeedEncrypted, ct);
// Reconstruct session with current nonce and client seed
return ProvablyFairSession.Rehydrate(
record.SessionId,
serverSeedBytes,
record.Commitment,
record.ClientSeed,
record.CurrentNonce);
}
}4.4 The Server Seed Rotation Endpoint
[ApiController]
[Route("api/v1/provably-fair")]
public sealed class ProvablyFairController : ControllerBase
{
private readonly IProvablyFairRepository _pfRepo;
private readonly ISessionCache _sessions;
/// <summary>
/// GET /api/v1/provably-fair/state
/// Returns the current session's Provably Fair state.
/// Always called on game launch to display commitment to player.
/// </summary>
[HttpGet("state")]
public async Task<IActionResult> GetState(CancellationToken ct)
{
string? token = ExtractBearerToken();
var session = await ValidateSession(token, ct);
var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct)
?? throw new NotFoundException("Provably Fair session not found.");
return Ok(new ProvablyFairStateResponse(
Commitment: pf.Commitment,
ClientSeed: pf.ClientSeed,
CurrentNonce: pf.CurrentNonce,
TotalSpinsPlayed: pf.CurrentNonce - 1,
NextSpinNonce: pf.CurrentNonce,
IsRevealed: pf.RevealedServerSeed is not null,
RevealedSeed: pf.RevealedServerSeed // null until revealed
));
}
/// <summary>
/// POST /api/v1/provably-fair/client-seed
/// Player submits a new client seed. Takes effect on next spin.
/// </summary>
[HttpPost("client-seed")]
public async Task<IActionResult> UpdateClientSeed(
[FromBody] UpdateClientSeedRequest request,
CancellationToken ct)
{
string? token = ExtractBearerToken();
var session = await ValidateSession(token, ct);
if (session.IsSpinInProgress)
return BadRequest(new { error = "Cannot change client seed while spin is in progress." });
// Validate client seed format
if (string.IsNullOrWhiteSpace(request.ClientSeed)
|| request.ClientSeed.Length < 16
|| !IsValidHex(request.ClientSeed))
{
return BadRequest(new { error = "Invalid client seed format." });
}
var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct);
if (pf is null) return NotFound();
string normalised = request.ClientSeed.ToLowerInvariant();
await _pfRepo.UpdateClientSeedAsync(
session.SessionId, normalised, pf.CurrentNonce, ct);
return Ok(new
{
accepted = true,
clientSeed = normalised,
effectiveFromNonce = pf.CurrentNonce
});
}
/// <summary>
/// POST /api/v1/provably-fair/rotate
/// Rotate the server seed: reveal current seed, generate new one.
/// Can only be called between spins (not during a spin or bonus round).
/// </summary>
[HttpPost("rotate")]
public async Task<IActionResult> RotateServerSeed(CancellationToken ct)
{
string? token = ExtractBearerToken();
var session = await ValidateSession(token, ct);
if (session.IsSpinInProgress || session.IsInBonus)
return Conflict(new { error =
"Cannot rotate server seed during an active spin or bonus round." });
var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct);
if (pf is null) return NotFound();
// Decrypt and reveal the current server seed
byte[] currentSeedBytes = await _keyManagement.DecryptAsync(
pf.ServerSeedEncrypted, ct);
string revealedHex = Convert.ToHexString(currentSeedBytes).ToLowerInvariant();
// Verify commitment matches (sanity check)
if (!ProvablyFairSession.VerifyCommitment(revealedHex, pf.Commitment))
{
// This should never happen — would indicate storage corruption
_logger.LogCritical("Commitment mismatch on reveal for session {Id}",
session.SessionId);
return StatusCode(500, new { error = "Internal integrity error." });
}
// Persist the reveal
await _pfRepo.RevealAsync(session.SessionId, revealedHex, ct);
// Create new Provably Fair session with fresh server seed
var newPfSession = ProvablyFairSession.Create(
session.SessionId + "-rotated-" + pf.CurrentNonce,
clientSeed: pf.ClientSeed // Carry over client seed
);
string encryptedNewSeed = await _keyManagement.EncryptAsync(
newPfSession.ServerSeedBytesForStorage, ct);
await _pfRepo.SaveSessionAsync(new ProvablyFairRecord(
SessionId: session.SessionId,
GameSessionId: session.SessionId,
ServerSeedEncrypted: encryptedNewSeed,
Commitment: newPfSession.Commitment,
ClientSeed: newPfSession.ClientSeed,
CurrentNonce: 1,
CreatedAt: DateTime.UtcNow,
RevealedServerSeed: null,
RevealedAt: null
), ct);
return Ok(new RotateServerSeedResponse(
// Old session — for player to verify
Previous: new PreviousSessionData(
Commitment: pf.Commitment,
RevealedSeed: revealedHex,
TotalSpins: pf.CurrentNonce - 1,
Verified: true // Server confirms self-verification
),
// New session
Next: new NextSessionData(
Commitment: newPfSession.Commitment,
ClientSeed: newPfSession.ClientSeed
)
));
}
/// <summary>
/// GET /api/v1/provably-fair/verify/{sessionId}
/// Returns all data needed to verify spins for a completed session.
/// Available after the server seed has been revealed.
/// </summary>
[HttpGet("verify/{sessionId}")]
public async Task<IActionResult> GetVerificationData(
string sessionId,
[FromQuery] long? fromNonce,
[FromQuery] long? toNonce,
CancellationToken ct)
{
var pf = await _pfRepo.LoadBySessionIdAsync(sessionId, ct);
if (pf is null) return NotFound();
if (pf.RevealedServerSeed is null)
return BadRequest(new { error =
"Server seed has not been revealed yet. " +
"The session must end before verification data is available." });
var spins = await _pfRepo.GetSpinsForVerificationAsync(
sessionId, fromNonce, toNonce, ct);
return Ok(new VerificationDataResponse(
SessionId: sessionId,
RevealedSeed: pf.RevealedServerSeed,
Commitment: pf.Commitment,
CommitmentValid: ProvablyFairSession.VerifyCommitment(
pf.RevealedServerSeed, pf.Commitment),
TotalSpins: pf.CurrentNonce - 1,
Spins: spins.Select(MapToVerificationDto).ToList()
));
}
private static bool IsValidHex(string s)
=> s.All(c => "0123456789abcdefABCDEF".Contains(c));
}Part V. Client-Side Implementation
5.1 TypeScript Provably Fair Module
The client-side verification module must be completely self-contained — it cannot depend on any server communication. The player should be able to copy this code and run it in any JavaScript environment.
/**
* Provably Fair verification module.
* Self-contained: no server communication required.
* Can be audited, copied, and run independently by players.
*
* Algorithm:
* 1. Verify: SHA256(revealedServerSeed) == commitment
* 2. For each spin:
* message_i = UTF8(`${clientSeed}:${spinNonce}:${reelIndex}`)
* hmac_i = HMAC-SHA256(key=serverSeedBytes, msg=messageBytes)
* stop_i = unbiasedInt(hmac_i, reelSize_i)
* 3. Compare derived stops with declared stops
*/
export interface SpinRecord {
spinNonce: number;
clientSeed: string; // hex string, active at time of spin
declaredStops: number[]; // stops announced in spin response
reelSizes: number[]; // from game config at time of spin
}
export interface VerificationResult {
commitmentValid: boolean;
spins: SpinVerificationResult[];
allSpinsValid: boolean;
invalidSpinCount: number;
}
export interface SpinVerificationResult {
spinNonce: number;
derivedStops: number[];
declaredStops: number[];
isValid: boolean;
discrepancies: string[];
}
export class ProvablyFairVerifier {
/**
* Verifies all spins in a completed session.
*/
async verifySession(
revealedServerSeed: string, // hex string, revealed at session end
commitment: string, // SHA256(serverSeed), published at session start
spins: SpinRecord[]
): Promise<VerificationResult> {
// Step 1: Verify the server seed matches the commitment
const commitmentValid = await this.verifyCommitment(
revealedServerSeed, commitment);
// Step 2: Verify each spin independently
const spinResults: SpinVerificationResult[] = [];
const serverSeedBytes = hexToBytes(revealedServerSeed);
for (const spin of spins) {
const result = await this.verifySpin(serverSeedBytes, spin);
spinResults.push(result);
}
const invalidCount = spinResults.filter(r => !r.isValid).length;
return {
commitmentValid,
spins: spinResults,
allSpinsValid: commitmentValid && invalidCount === 0,
invalidSpinCount: invalidCount,
};
}
/**
* Verifies SHA256(revealedServerSeed) === commitment.
*/
async verifyCommitment(
revealedServerSeed: string,
commitment: string
): Promise<boolean> {
const seedBytes = hexToBytes(revealedServerSeed);
const hashBuffer = await crypto.subtle.digest('SHA-256', seedBytes);
const hashHex = bytesToHex(new Uint8Array(hashBuffer));
return hashHex.toLowerCase() === commitment.toLowerCase();
}
/**
* Derives reel stops for a single spin and compares to declared stops.
*/
async verifySpin(
serverSeedBytes: Uint8Array,
spin: SpinRecord
): Promise<SpinVerificationResult> {
const derivedStops: number[] = [];
for (let reelIndex = 0; reelIndex < spin.reelSizes.length; reelIndex++) {
const stop = await this.deriveReelStop(
serverSeedBytes,
spin.clientSeed,
spin.spinNonce,
reelIndex,
spin.reelSizes[reelIndex]
);
derivedStops.push(stop);
}
const discrepancies: string[] = [];
let isValid = true;
for (let i = 0; i < derivedStops.length; i++) {
if (derivedStops[i] !== spin.declaredStops[i]) {
isValid = false;
discrepancies.push(
`Reel ${i}: derived=${derivedStops[i]}, declared=${spin.declaredStops[i]}`
);
}
}
return {
spinNonce: spin.spinNonce,
derivedStops,
declaredStops: spin.declaredStops,
isValid,
discrepancies,
};
}
/**
* Core derivation: HMAC-SHA256(serverSeed, clientSeed:nonce:reelIndex)
* → unbiased integer in [0, reelSize)
*/
private async deriveReelStop(
serverSeedBytes: Uint8Array,
clientSeed: string,
spinNonce: number,
reelIndex: number,
reelSize: number
): Promise<number> {
// Import server seed as HMAC key
const key = await crypto.subtle.importKey(
'raw',
serverSeedBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Build message: "{clientSeed}:{spinNonce}:{reelIndex}"
const message = `${clientSeed}:${spinNonce}:${reelIndex}`;
const messageBytes = new TextEncoder().encode(message);
// Compute HMAC-SHA256
const hmacBuffer = await crypto.subtle.sign('HMAC', key, messageBytes);
const hmacBytes = new Uint8Array(hmacBuffer);
// Derive unbiased integer
return this.deriveUnbiasedInt(hmacBytes, reelSize);
}
/**
* Extracts an unbiased integer in [0, range) from hash bytes.
* Uses rejection sampling to eliminate modular bias.
*/
private deriveUnbiasedInt(hashBytes: Uint8Array, range: number): number {
const view = new DataView(hashBytes.buffer);
const threshold = (2 ** 32) % range; // Values below this are biased
for (let offset = 0; offset + 4 <= hashBytes.length; offset += 4) {
const value = view.getUint32(offset, true); // little-endian, matching C#
if (value >= threshold) {
return value % range;
}
// If biased: try next 4-byte slice
}
// Fallback (essentially never reached for range ≤ 2^30)
return view.getUint32(0, true) % range;
}
}
// ── Utility functions ──────────────────────────────────────────────
function hexToBytes(hex: string): Uint8Array {
const cleanHex = hex.toLowerCase().replace(/[^0-9a-f]/g, '');
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
}
return bytes;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// ── Standalone verification function for player use ────────────────
/**
* Simplified verification function for players to run in the browser console.
* Copy-paste this into any browser's developer console to verify a spin.
*/
export async function verifySpinInConsole(
revealedServerSeed: string,
clientSeed: string,
spinNonce: number,
declaredStops: number[],
reelSizes: number[]
): Promise<void> {
console.log('=== Provably Fair Verification ===');
console.log(`Server seed: ${revealedServerSeed}`);
console.log(`Client seed: ${clientSeed}`);
console.log(`Spin nonce: ${spinNonce}`);
console.log(`Reel sizes: [${reelSizes.join(', ')}]`);
console.log(`Declared: [${declaredStops.join(', ')}]`);
const verifier = new ProvablyFairVerifier();
const seedBytes = hexToBytes(revealedServerSeed);
const result = await verifier.verifySpin(seedBytes, {
spinNonce, clientSeed, declaredStops, reelSizes
});
console.log(`Derived: [${result.derivedStops.join(', ')}]`);
console.log(`Result: ${result.isValid ? '✓ VALID' : '✗ INVALID'}`);
if (!result.isValid) {
console.error('Discrepancies:');
result.discrepancies.forEach(d => console.error(' ' + d));
}
}5.2 The Client-Side Provably Fair UI
/**
* Provably Fair UI component for the game client.
* Displays the current session's cryptographic state
* and allows players to change their client seed.
*/
export class ProvablyFairPanel {
private state: ProvablyFairStateResponse | null = null;
private network: ProvablyFairNetworkLayer;
async show(): Promise<void> {
this.state = await this.network.getState();
this.render();
}
private render(): void {
if (!this.state) return;
const html = `
<div class="pf-panel">
<h3>🔐 Provably Fair</h3>
<div class="pf-section">
<label>Server Seed Commitment (SHA-256)</label>
<div class="pf-hash" title="This hash proves the server committed
to its seed before you played. Verify it after your session.">
${this.formatHash(this.state.commitment)}
</div>
<small>Generated before your session began.
Cannot change without detection.</small>
</div>
<div class="pf-section">
<label>Your Client Seed</label>
<div class="pf-seed">${this.state.clientSeed}</div>
<button onclick="pfPanel.changeClientSeed()">
🎲 Generate New Client Seed
</button>
<small>Your contribution to the outcome. Change anytime.</small>
</div>
<div class="pf-section">
<label>Current Nonce</label>
<div class="pf-nonce">${this.state.currentNonce}</div>
<small>Spin number. Increments with each spin.</small>
</div>
<div class="pf-section">
<button onclick="pfPanel.rotateServerSeed()" class="pf-rotate-btn">
🔄 Rotate Server Seed
</button>
<small>Reveals the current seed so you can verify past spins.
A new session begins with a fresh server seed.</small>
</div>
${this.state.isRevealed ? this.renderVerificationSection() : ''}
<a href="/verify" target="_blank" class="pf-verify-link">
🔍 Verify session history
</a>
</div>
`;
document.getElementById('pf-container')!.innerHTML = html;
}
async changeClientSeed(): Promise<void> {
// Generate 32 cryptographically random bytes
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
const newSeed = Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const result = await this.network.updateClientSeed(newSeed);
if (result.accepted) {
this.state!.clientSeed = result.clientSeed;
this.render();
}
}
async rotateServerSeed(): Promise<void> {
if (!confirm(
'Rotating the server seed will reveal your current seed ' +
'so you can verify past spins. Continue?')) return;
const result = await this.network.rotateSeed();
// Show verification data for the revealed session
this.showRevealedSessionData(result.previous);
// Update with new session data
this.state!.commitment = result.next.commitment;
this.state!.clientSeed = result.next.clientSeed;
this.state!.currentNonce = 1;
this.render();
}
private renderVerificationSection(): string {
return `
<div class="pf-section pf-revealed">
<label>✅ Server Seed Revealed</label>
<div class="pf-hash">${this.formatHash(this.state!.revealedSeed!)}</div>
<small>Verify: SHA256("${this.state!.revealedSeed}")
should equal the commitment above.</small>
</div>
`;
}
private formatHash(hash: string): string {
// Display in 8-character chunks for readability
return hash.match(/.{1,8}/g)?.join(' ') ?? hash;
}
}Part VI. The Independent Verification Page
6.1 A Complete Standalone Verifier
This is the self-contained HTML page players can bookmark and use to verify any past session — entirely client-side, no network requests, no trust required:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Crystal Forge — Provably Fair Verifier</title>
<style>
body { font-family: monospace; max-width: 800px; margin: 40px auto; padding: 20px; }
input, textarea { width: 100%; box-sizing: border-box; padding: 8px; margin: 4px 0; }
.valid { color: #22c55e; font-weight: bold; }
.invalid { color: #ef4444; font-weight: bold; }
.result { background: #1a1a2e; color: #e2e8f0; padding: 12px; margin: 8px 0;
border-radius: 4px; font-size: 0.85em; }
button { background: #6366f1; color: white; border: none; padding: 10px 20px;
cursor: pointer; border-radius: 4px; margin: 8px 0; }
</style>
</head>
<body>
<h1>🔐 Crystal Forge — Provably Fair Verifier</h1>
<p>Verify any past session entirely client-side. No data is sent to any server.</p>
<h2>Step 1: Verify Server Seed Commitment</h2>
<label>Revealed Server Seed (after session end):</label>
<input id="serverSeed" type="text" placeholder="64-char hex string...">
<label>Commitment (SHA-256, shown at session start):</label>
<input id="commitment" type="text" placeholder="64-char hex string...">
<button onclick="verifyCommitment()">Verify Commitment</button>
<div id="commitmentResult" class="result"></div>
<h2>Step 2: Verify Individual Spin</h2>
<label>Client Seed (active at time of spin):</label>
<input id="clientSeed" type="text" placeholder="64-char hex string...">
<label>Spin Nonce:</label>
<input id="spinNonce" type="number" value="1" min="1">
<label>Reel Sizes (comma-separated):</label>
<input id="reelSizes" type="text" value="32,32,32,32,32">
<label>Declared Stops from spin response (comma-separated):</label>
<input id="declaredStops" type="text" placeholder="14,7,22,3,18">
<button onclick="verifySpin()">Verify Spin</button>
<div id="spinResult" class="result"></div>
<h2>Step 3: Batch Verify from JSON</h2>
<label>Paste verification JSON (from /api/v1/provably-fair/verify/{sessionId}):</label>
<textarea id="verificationJson" rows="10" placeholder='{"revealedSeed":"...","spins":[...]}'></textarea>
<button onclick="verifyBatch()">Verify All Spins</button>
<div id="batchResult" class="result"></div>
<script>
// ── Crypto utilities ──────────────────────────────────────────
function hexToBytes(hex) {
const clean = hex.toLowerCase().replace(/[^0-9a-f]/g, '');
const bytes = new Uint8Array(clean.length / 2);
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(clean.substr(i * 2, 2), 16);
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function sha256(bytes) {
const hash = await crypto.subtle.digest('SHA-256', bytes);
return bytesToHex(new Uint8Array(hash));
}
async function hmacSha256(keyBytes, messageBytes) {
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, messageBytes);
return new Uint8Array(sig);
}
function unbiasedInt(hashBytes, range) {
const view = new DataView(hashBytes.buffer);
const threshold = (2 ** 32) % range;
for (let offset = 0; offset + 4 <= hashBytes.length; offset += 4) {
const value = view.getUint32(offset, true);
if (value >= threshold) return value % range;
}
return view.getUint32(0, true) % range;
}
async function deriveStop(seedBytes, clientSeed, nonce, reelIdx, reelSize) {
const msg = new TextEncoder().encode(`${clientSeed}:${nonce}:${reelIdx}`);
const hmac = await hmacSha256(seedBytes, msg);
return unbiasedInt(hmac, reelSize);
}
// ── Verification functions ─────────────────────────────────────
async function verifyCommitment() {
const serverSeed = document.getElementById('serverSeed').value.trim();
const commitment = document.getElementById('commitment').value.trim();
const el = document.getElementById('commitmentResult');
if (!serverSeed || !commitment) {
el.innerHTML = 'Please enter both values.'; return;
}
const seedBytes = hexToBytes(serverSeed);
const computed = await sha256(seedBytes);
const isValid = computed === commitment.toLowerCase();
el.innerHTML = isValid
? `<span class="valid">✓ VALID</span><br>
SHA256("${serverSeed.slice(0,8)}...") = ${computed}`
: `<span class="invalid">✗ INVALID</span><br>
Expected: ${commitment.toLowerCase()}<br>
Computed: ${computed}<br>
These do not match — the server changed its seed!`;
}
async function verifySpin() {
const serverSeed = document.getElementById('serverSeed').value.trim();
const clientSeed = document.getElementById('clientSeed').value.trim();
const nonce = parseInt(document.getElementById('spinNonce').value);
const sizes = document.getElementById('reelSizes').value
.split(',').map(s => parseInt(s.trim()));
const declared = document.getElementById('declaredStops').value
.split(',').map(s => parseInt(s.trim()));
const el = document.getElementById('spinResult');
if (!serverSeed || !clientSeed) {
el.innerHTML = 'Please complete Step 1 first.'; return;
}
const seedBytes = hexToBytes(serverSeed);
const derived = [];
for (let i = 0; i < sizes.length; i++) {
const stop = await deriveStop(seedBytes, clientSeed, nonce, i, sizes[i]);
derived.push(stop);
}
const isValid = derived.every((s, i) => s === declared[i]);
const details = derived.map((s, i) =>
`Reel ${i+1}: derived=${s}, declared=${declared[i]} ${s===declared[i]?'✓':'✗'}`
).join('<br>');
el.innerHTML = `
<span class="${isValid ? 'valid' : 'invalid'}">
${isValid ? '✓ ALL STOPS VERIFIED' : '✗ DISCREPANCY DETECTED'}
</span><br><br>
Server seed: ${serverSeed.slice(0,8)}...<br>
Client seed: ${clientSeed.slice(0,8)}...<br>
Nonce: ${nonce}<br><br>
${details}
`;
}
async function verifyBatch() {
const json = document.getElementById('verificationJson').value;
const el = document.getElementById('batchResult');
let data;
try { data = JSON.parse(json); }
catch { el.innerHTML = 'Invalid JSON.'; return; }
const seedBytes = hexToBytes(data.revealedSeed);
const commitmentValid = (await sha256(seedBytes)) === data.commitment.toLowerCase();
let results = [`Commitment: ${commitmentValid ? '✓ Valid' : '✗ INVALID'}<br>`];
let invalid = 0;
for (const spin of data.spins) {
const derived = [];
for (let i = 0; i < spin.reelSizes.length; i++) {
derived.push(await deriveStop(seedBytes, spin.clientSeed,
spin.spinNonce, i, spin.reelSizes[i]));
}
const ok = derived.every((s, i) => s === spin.declaredStops[i]);
if (!ok) invalid++;
results.push(
`Nonce ${spin.spinNonce}: ${ok ? '✓' : '✗ FAIL'} ` +
`[${derived.join(',')}] vs [${spin.declaredStops.join(',')}]`
);
}
el.innerHTML = `
<strong>Results: ${data.spins.length - invalid}/${data.spins.length} valid</strong><br>
${results.join('<br>')}
`;
}
</script>
</body>
</html>Part VII. Security Analysis
7.1 Attack Model and Resistance
Attack 1: Server pre-selects outcome, then generates fake server seed
The server decides the outcome first (always lose), then tries to construct a server seed that produces the desired reel stops with the player's client seed.
Resistance: Impossible by SHA-256 pre-image resistance. Given a desired output from HMAC-SHA256, finding the key (server seed) that produces it requires brute-forcing SHA-256. Cost: 2^256 operations. Computationally infeasible.
Attack 2: Server generates multiple server seeds, picks the one that produces the best outcome for the house
The server generates N server seeds, evaluates each one against the expected client seed, and publishes the commitment of the most favourable one.
Resistance: Partially prevented by timing. The commitment is published before the player provides their client seed (or before their client seed changes for the next spin). If the server generates the commitment before knowing the client seed, it cannot cherry-pick.
However: if the server can observe the client seed before generating the commitment (e.g., in a broken implementation where the sequence is wrong), this attack is feasible. Protocol correctness matters. The commitment must be published before the player provides any seed that will be used in combination with it.
Implementation protection:
// ✓ CORRECT: Commitment published before client seed is known
var pf = ProvablyFairSession.Create(sessionId); // Generates server seed + commitment
await database.SaveCommitmentAsync(pf.Commitment); // Persisted
await SendCommitmentToPlayer(pf.Commitment); // Published to player
// Later: player provides client seed
pf.UpdateClientSeed(playerProvidedSeed); // Only now is client seed knownAttack 3: Client uses a predictable client seed
If the client generates its seed with a weak RNG (e.g., Math.random()), the server can predict the client seed in advance and bias the server seed selection.
Mitigation: The server should always use crypto.subtle.getRandomValues() for the server-generated default client seed, document that players should also use CSPRNG for custom seeds, and optionally provide server-side entropy mixing.
Attack 4: Replay attack — reusing a nonce
If the same (server seed, client seed, nonce) triple is used twice, the outcomes are identical. An attacker who knows an outcome was going to be favourable could try to replay it.
Resistance: The nonce is strictly monotonically increasing per session and stored persistently. It is impossible for the same nonce to be used twice within the same session. The nonce is updated to persistent storage before the spin response is sent.
Attack 5: Commitment is changed after publication
The server publishes commitment C, then later the player's session looks up the commitment and it has been changed to C'.
Resistance: The commitment is immutable once published. It is stored in the database with a created-at timestamp and no update path. The audit log records the commitment at the time of each spin — any discrepancy between the stored commitment and the audit log is immediately detectable.
7.2 The Nonce Must Be Persisted Before Outcome Use
A subtle but critical ordering constraint:
// ❌ WRONG ORDER: nonce incremented AFTER outcome is committed to response
int[] stops = pfSession.GenerateReelStops(config.ReelSizes);
// pfSession.SpinNonce is now N+1 (internally)
var outcome = engine.Evaluate(stops); // Win calculated
var response = BuildResponse(outcome); // Response prepared
await SendResponseToClient(response); // Response sent
await pfRepo.UpdateNonceAsync(sessionId, N+1); // Nonce saved LAST
// If server crashes here: nonce is still N in database.
// Player reconnects, same nonce N is used again.
// Outcome is different (no client seed change) = same stops → same outcome.
// Player saw different outcome on first load. DISCREPANCY.
// ✓ CORRECT ORDER: nonce persisted BEFORE response is used
int[] stops = pfSession.GenerateReelStops(config.ReelSizes);
await pfRepo.UpdateNonceAsync(sessionId, pfSession.SpinNonce); // FIRST
// If server crashes here: nonce is N+1 in database.
// Player reconnects, nonce is N+1. The spin at nonce N is lost.
// But: there's a pending spin record showing stops for nonce N.
// Recovery: use the stored stops from the verification record.
var outcome = engine.Evaluate(stops);
var response = BuildResponse(outcome);
await SendResponseToClient(response);Part VIII. Provably Fair and Regulatory Certification
8.1 The Relationship Between the Two Systems
Provably Fair and traditional certification address overlapping but distinct concerns:
Provably Fair | Regulatory Certification |
Outcome was determined by | The RNG output is statistically |
Does NOT prove: | Does NOT prove: |
8.2 Regulatory Acceptance of Provably Fair
The regulatory picture varies by jurisdiction:
UK (UKGC): No specific Provably Fair requirement or prohibition. A Provably Fair layer does not reduce the certification requirements — the game still needs GLI or BMM certification. Some operators use Provably Fair as an additional transparency feature.
Malta (MGA): Similar to UK. Provably Fair is voluntary but not a substitute for certification.
Netherlands (KSA): No specific Provably Fair requirements. The KSA's focus is on Net Win Rate and player protection features.
Curaçao: Most crypto-focused operators use Curaçao licences. Provably Fair is common in this space, sometimes as the primary trust mechanism in lieu of traditional certification.
Consensus view: Provably Fair and traditional certification are complementary. Using both gives the strongest possible trust proposition: the game is independently certified AND players can self-verify individual outcomes.
8.3 What the Certification Lab Makes of Provably Fair
When GLI or BMM reviews a Provably Fair slot, their additional checks are:
Does the HMAC derivation match the documented algorithm exactly? They compute HMAC-SHA256 for test vectors and verify the game produces the same stops.
Is the server seed generated from a certified entropy source? The CSPRNG used to generate the server seed must meet the same GLI-11 requirements as the primary game RNG.
Is the commitment published before the client seed is provided? They verify the protocol sequence through audit log inspection.
Is the nonce persisted before the outcome is consumed? They test crash recovery scenarios.
Can the verification algorithm in the player-facing code be independently reproduced? They run the documented verification algorithm on known test vectors.
Part IX. Testing the Provably Fair System
9.1 Unit Tests with Known Test Vectors
[TestClass]
public class ProvablyFairTests
{
// Test vector: known inputs → known outputs
// These must be published in documentation so players can verify the algorithm
private const string TestServerSeedHex =
"b94f6f125c79e3a5ffaa826f584c10d7cc3b2d13f2f3b813e0c42c3697f9f21a";
private const string TestClientSeed =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private const long TestNonce = 1;
[TestMethod]
public void DeriveStop_KnownTestVector_ReelSize32()
{
byte[] serverSeedBytes = Convert.FromHexString(TestServerSeedHex);
byte[] keyBytes = serverSeedBytes;
string message = $"{TestClientSeed}:{TestNonce}:0";
byte[] msgBytes = Encoding.UTF8.GetBytes(message);
byte[] hmac = HMACSHA256.HashData(keyBytes, msgBytes);
int stop = ProvablyFairSession.TestDeriveUnbiasedInt(hmac, 32);
// Pre-computed expected value for this test vector:
// HMAC-SHA256(key=TestServerSeed, msg="TestClientSeed:1:0")
// First 4 bytes (little-endian uint32) mod 32 — no rejection needed
Assert.AreEqual(14, stop, // expected stop position
"Reel 0 stop position mismatch for test vector.");
}
[TestMethod]
public void CommitmentVerification_MatchingSeeds_ReturnsTrue()
{
var session = ProvablyFairSession.Create("test-session");
string revealed = session.RevealServerSeed();
bool isValid = ProvablyFairSession.VerifyCommitment(
revealed, session.Commitment);
Assert.IsTrue(isValid, "Commitment should match revealed seed.");
}
[TestMethod]
public void CommitmentVerification_TamperedSeed_ReturnsFalse()
{
var session = ProvablyFairSession.Create("test-session");
string revealed = session.RevealServerSeed();
// Tamper: change last character
string tampered = revealed[..^1] + (revealed[^1] == 'a' ? 'b' : 'a');
bool isValid = ProvablyFairSession.VerifyCommitment(
tampered, session.Commitment);
Assert.IsFalse(isValid, "Tampered seed should not verify.");
}
[TestMethod]
public void GenerateStops_ThenVerify_StopsMatch()
{
var pf = ProvablyFairSession.Create("test-session");
var reelSizes = new[] { 32, 32, 32, 32, 32 };
long nonce = pf.SpinNonce;
string cs = pf.ClientSeed;
int[] stops = pf.GenerateReelStops(reelSizes);
// Reveal
string revealed = pf.RevealServerSeed();
// Independent verification
int[] derived = ProvablyFairSession.DeriveStopsForSpin(
revealed, cs, nonce, reelSizes);
CollectionAssert.AreEqual(stops, derived,
"Derived stops must match original stops.");
}
[TestMethod]
public void NonceIncrements_AfterEachSpin()
{
var pf = ProvablyFairSession.Create("test-session");
Assert.AreEqual(1L, pf.SpinNonce);
pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
Assert.AreEqual(2L, pf.SpinNonce);
pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
Assert.AreEqual(3L, pf.SpinNonce);
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void GenerateStops_AfterReveal_Throws()
{
var pf = ProvablyFairSession.Create("test-session");
pf.RevealServerSeed();
pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
// Should throw: cannot generate after reveal
}
[TestMethod]
public void ClientSeedChange_DoesNotAffectPreviousSpins()
{
var pf = ProvablyFairSession.Create("test-session");
string originalClientSeed = pf.ClientSeed;
var reelSizes = new[] { 32, 32, 32, 32, 32 };
long nonce1 = pf.SpinNonce;
int[] stops1 = pf.GenerateReelStops(reelSizes); // Nonce 1
pf.UpdateClientSeed(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
long nonce2 = pf.SpinNonce;
int[] stops2 = pf.GenerateReelStops(reelSizes); // Nonce 2, new seed
string revealed = pf.RevealServerSeed();
// Verify spin 1 with original client seed
int[] derived1 = ProvablyFairSession.DeriveStopsForSpin(
revealed, originalClientSeed, nonce1, reelSizes);
CollectionAssert.AreEqual(stops1, derived1,
"Spin 1 must verify with original client seed.");
// Verify spin 2 with new client seed
int[] derived2 = ProvablyFairSession.DeriveStopsForSpin(
revealed, pf.ClientSeed, nonce2, reelSizes);
CollectionAssert.AreEqual(stops2, derived2,
"Spin 2 must verify with new client seed.");
// Spin 1 must NOT verify with new seed (different derivation)
int[] wrong1 = ProvablyFairSession.DeriveStopsForSpin(
revealed, pf.ClientSeed, nonce1, reelSizes);
// They might coincidentally match, but it's very unlikely
// (probability 1/(32^5) ≈ 0.0003%)
// This test verifies that the wrong seed changes the derivation
bool allMatch = stops1.Zip(wrong1).All(pair => pair.First == pair.Second);
// Don't assert inequality (could be coincidental) — just verify the algorithm
// produces different inputs
string msg1 = $"{originalClientSeed}:{nonce1}:0";
string msg2 = $"{pf.ClientSeed}:{nonce1}:0";
Assert.AreNotEqual(msg1, msg2, "Different seeds must produce different messages.");
}
[TestMethod]
public void UnbiasedInt_AllValuesEquallyLikely_ChiSquare()
{
// Statistical test: does DeriveUnbiasedInt produce uniform output?
const int reelSize = 32;
const int samples = 320_000;
var observed = new long[reelSize];
for (int i = 0; i < samples; i++)
{
byte[] randomBytes = RandomNumberGenerator.GetBytes(32);
int stop = ProvablyFairSession.TestDeriveUnbiasedInt(
randomBytes, reelSize);
observed[stop]++;
}
double expected = (double)samples / reelSize;
double chiSquare = observed.Sum(o => Math.Pow(o - expected, 2) / expected);
// Chi-square with df=31, alpha=0.01: critical value = 50.89
// If chiSquare > 50.89, distribution is not uniform at 99% confidence
Assert.IsTrue(chiSquare < 50.89,
$"Chi-square = {chiSquare:F2}, expected < 50.89. " +
"DeriveUnbiasedInt may not be producing uniform output.");
}
}Summary
Provably Fair is not a marketing feature - it is a cryptographic proof of fairness that fundamentally changes the trust relationship between a game provider and its players. When implemented correctly, it allows any player to verify independently, without trusting any institution, that every spin they received was determined by a commitment the server made before the bet was placed and could not have been changed after.
The key implementation principles from this article:
The commitment is published before the client seed is known. This ordering is the entire security guarantee. A system where the server generates the commitment after knowing the client seed is not Provably Fair - it is theatrical Provably Fair with no actual security property.
HMAC-SHA256 is the right primitive. Plain SHA-256 of a concatenation is not equivalent - HMAC provides a proper keyed construction where knowing the output for many messages reveals nothing about the key (server seed).
The nonce must be persisted before the outcome is consumed. Any crash between generating stops and persisting the nonce creates an opportunity for nonce reuse. Persist first, use second.
The verification algorithm must be public, documented, and independently reproducible. The standalone verification page — no dependencies, no network requests, copy-paste into any browser - is the gold standard. If players cannot verify without trusting your infrastructure, you have not implemented Provably Fair.
Provably Fair and regulatory certification are complementary. One proves individual outcomes; the other proves the mathematical model. Both are needed for the strongest possible trust proposition. Neither substitutes for the other.
