Skip to main content

Architecture Overview

The provably fair system is implemented across three main components:
  1. Core Library (lib/provably-fair.ts) - Cryptographic functions
  2. Seed Management API (api/provably-fair/seed/route.ts) - Seed generation and rotation
  3. Case Opening API (api/cases/open/route.ts) - Result calculation and recording

Cryptographic Functions

Server Seed Generation

Server seeds are generated using Node.js’s cryptographically secure random number generator:
import crypto from 'crypto';

export function generateServerSeed(): string {
    return crypto.randomBytes(32).toString('hex');
}
Technical Details:
  • Uses crypto.randomBytes() which sources from the OS’s CSPRNG
  • Generates 32 bytes (256 bits) of entropy
  • Converts to 64 hexadecimal characters for easy handling
  • Provides ~10^77 possible combinations

Client Seed Generation

Client seeds follow a similar approach but are shorter by default:
export function generateClientSeed(): string {
    return crypto.randomBytes(16).toString('hex');
}
Technical Details:
  • Generates 16 bytes (128 bits) of entropy
  • Results in 32 hexadecimal characters
  • Can be replaced with any user-provided string
  • Provides ~10^38 possible combinations

Seed Hashing (Commitment)

Before case opening, the server seed is hashed using SHA-256:
export function hashSeed(seed: string): string {
    return crypto.createHash('sha256').update(seed).digest('hex');
}
Why SHA-256?
  • One-way function: Cannot reverse hash to get original seed
  • Deterministic: Same input always produces same output
  • Collision-resistant: Virtually impossible to find two inputs with same hash
  • Industry standard for cryptographic commitments

Roll Calculation Algorithm

The core of the provably fair system is the roll calculation function:
export function calculateRollResult(
    serverSeed: string, 
    clientSeed: string, 
    nonce: number
): number {
    const message = `${clientSeed}-${nonce}`;
    const hmac = crypto.createHmac('sha256', serverSeed);
    hmac.update(message);
    const hex = hmac.digest('hex');

    // Take the first 8 characters (4 bytes) -> 32-bit integer
    const subHex = hex.substring(0, 8);
    const decimal = parseInt(subHex, 16);

    // Max value of 8 hex chars is 0xFFFFFFFF = 4294967295
    const MAX_VAL = 0xFFFFFFFF;

    // Return a float between 0 and 1
    return decimal / (MAX_VAL + 1);
}

Algorithm Breakdown

Step 1: Construct Message

const message = `${clientSeed}-${nonce}`;
Combines the client seed and nonce with a hyphen separator. Example:
  • Client Seed: a3f5e9c1b2d4f6a8
  • Nonce: 42
  • Message: a3f5e9c1b2d4f6a8-42

Step 2: Calculate HMAC-SHA256

const hmac = crypto.createHmac('sha256', serverSeed);
hmac.update(message);
const hex = hmac.digest('hex');
HMAC (Hash-based Message Authentication Code) provides:
  • Determinism: Same inputs always produce same output
  • Unpredictability: Cannot predict output without knowing server seed
  • Uniformity: Output is evenly distributed across possible values
Why HMAC instead of simple hashing?
  • HMAC uses the server seed as a secret key
  • Prevents rainbow table attacks
  • Standard practice in provably fair implementations

Step 3: Extract Numeric Value

const subHex = hex.substring(0, 8);
const decimal = parseInt(subHex, 16);
Converts the first 8 hexadecimal characters to a decimal integer:
  • 8 hex chars = 4 bytes = 32 bits
  • Range: 0 to 4,294,967,295 (0xFFFFFFFF)
Why only first 8 characters?
  • SHA-256 produces 64 hex characters (256 bits)
  • Using 32 bits provides sufficient randomness
  • More bits don’t improve distribution for our use case
  • Matches industry standards (similar to Stake.com, CSGORoll)

Step 4: Normalize to 0-1 Range

const MAX_VAL = 0xFFFFFFFF;
return decimal / (MAX_VAL + 1);
Divides by (MAX_VAL + 1) to get a float between 0 and 1:
  • Ensures 0 ≤ result < 1
  • Provides uniform distribution
  • Compatible with probability-based item selection

Mathematical Properties

Uniformity: The division by (MAX_VAL + 1) ensures each possible roll value has equal probability:
P(roll = x) = 1 / 4,294,967,296 for any x in [0, 1)
Determinism: For any given combination of server seed, client seed, and nonce:
roll = f(serverSeed, clientSeed, nonce)
The function f always produces the same output.

Item Selection Algorithm

Once we have a roll value (0 to 1), we select the winning item based on probability weights:
export function getWinningItem<T extends { probability: number }>(
    items: T[], 
    roll: number
): T {
    const totalWeight = items.reduce((sum, item) => sum + (item.probability || 0), 0);
    let currentThreshold = 0;
    const target = roll * totalWeight;

    for (const item of items) {
        currentThreshold += (item.probability || 0);
        if (target < currentThreshold) {
            return item;
        }
    }

    // Fallback
    return items[items.length - 1];
}

How It Works

Example: Consider a case with 3 items:
  • Common Item: probability = 70
  • Rare Item: probability = 25
  • Legendary Item: probability = 5
  • Total Weight = 100
Probability Ranges:
  • Common: 0 to 0.70 (70% chance)
  • Rare: 0.70 to 0.95 (25% chance)
  • Legendary: 0.95 to 1.00 (5% chance)
If roll = 0.732:
  1. target = 0.732 * 100 = 73.2
  2. Check Common: 73.2 < 70? No
  3. Check Rare: 73.2 < (70 + 25)? Yes → Rare Item wins

API Integration

Seed Management Endpoint

GET /api/provably-fair/seed Retrieves current seed information:
return NextResponse.json({
    server_seed_hash: hashSeed(seeds.server_seed),
    client_seed: seeds.client_seed,
    nonce: seeds.nonce
});
POST /api/provably-fair/seed Rotates seeds and resets nonce:
const newServerSeed = generateServerSeed();
const newClientSeed = client_seed || generateClientSeed();

await supabase
    .from('user_seeds')
    .update({
        server_seed: newServerSeed,
        client_seed: newClientSeed,
        nonce: 0,
        updated_at: new Date().toISOString()
    })
    .eq('user_id', user.id);

Case Opening Flow

POST /api/cases/open From app/api/cases/open/route.ts:52-53:
const nonce = seeds.nonce + 1;
const rollValue = calculateRollResult(seeds.server_seed, seeds.client_seed, nonce);
Complete flow:
  1. Fetch user’s current seeds (line 29-38)
  2. Update client seed if provided (line 41-49)
  3. Calculate roll result (line 52-53)
  4. Select winning item (line 71)
  5. Update nonce (line 74-77)
  6. Record game roll (line 80-90)
  7. Return result with fairness proof (line 94-102)
return NextResponse.json({
    winner: winnerItem,
    fairness: {
        server_seed_hash: hashSeed(seeds.server_seed),
        client_seed: seeds.client_seed,
        nonce: nonce,
        roll_value: rollValue
    }
});

Database Schema

user_seeds Table

CREATE TABLE user_seeds (
    user_id UUID PRIMARY KEY,
    server_seed TEXT NOT NULL,
    client_seed TEXT NOT NULL,
    nonce INTEGER DEFAULT 0,
    updated_at TIMESTAMP DEFAULT NOW()
);

game_rolls Table (Audit Log)

From app/api/cases/open/route.ts:80-90:
await supabase.from('game_rolls').insert({
    user_id: user.id,
    case_id: caseId,
    server_seed: seeds.server_seed,
    client_seed: seeds.client_seed,
    nonce: nonce,
    roll_result: Math.floor(rollValue * 1000000),
    item_won_id: winnerItem.id
});
Stores every game for auditing and verification purposes.

Security Considerations

Seed Rotation Best Practices

  1. When to rotate:
    • User explicitly requests seed change
    • After a certain number of games (e.g., 1000)
    • User suspects compromise
  2. What gets revealed:
    • Previous server seed (after rotation)
    • All game rolls using that seed can now be verified
  3. Nonce reset:
    • Nonce resets to 0 on seed rotation
    • Prevents nonce overflow
    • Maintains clean audit trail

Timing Attack Prevention

From app/api/provably-fair/seed/route.ts:21-38, seeds are created atomically:
const { data: newSeeds, error } = await supabase
    .from('user_seeds')
    .insert({
        user_id: user.id,
        server_seed: serverSeed,
        client_seed: clientSeed,
        nonce: nonce
    })
    .select()
    .single();
This prevents race conditions where multiple requests could create inconsistent state.

Performance Optimization

Cryptographic Operations

  • HMAC-SHA256: ~0.1ms per operation
  • SHA-256 hash: ~0.05ms per operation
  • Random byte generation: ~0.01ms per operation
These are fast enough for real-time case opening.

Database Queries

Optimizations in the implementation:
  • Single query to fetch seeds
  • Single update for nonce increment
  • Asynchronous game roll insertion (doesn’t block response)

Testing and Validation

Verify Determinism

const roll1 = calculateRollResult('serverseed123', 'clientseed456', 1);
const roll2 = calculateRollResult('serverseed123', 'clientseed456', 1);
console.assert(roll1 === roll2, 'Rolls must be deterministic');

Verify Distribution

const rolls = [];
for (let i = 0; i < 10000; i++) {
    rolls.push(calculateRollResult('seed', 'client', i));
}
const avg = rolls.reduce((a, b) => a + b) / rolls.length;
console.assert(Math.abs(avg - 0.5) < 0.01, 'Distribution should be uniform');

Next Steps

Verification Guide

Learn how to verify your case opening results using these algorithms

Build docs developers (and LLMs) love