Duel.com Provably Fair Audit: Full Source Code Analysis of 12 In-House Games

This is a technical cryptographic audit. It is not a casino review, not a recommendation, and not an advertisement. No affiliate links, no promotional language, no “sign up” buttons. This document exists for one purpose: to answer the question “does this platform’s provably fair system actually work?” — with evidence.

We extracted every fairness-related JavaScript module from Duel.com’s production frontend, decompiled the algorithms, and verified their cryptographic correctness against established standards. The creator of Duel.com (known as Monarch) gave us explicit permission to publish this analysis.

Audit Scope

Platform: Duel.com

Category: In-house games (“Originals”) only — third-party provider slots and live dealer are out of scope

Games audited: 12 (Dice, Mines, Keno, Plinko, Blackjack, Video Poker, Cross Road, PF Slots, Coinflip, Crash, Castle Roulette, Rock Paper Scissors)

Method: Static analysis of production JavaScript bundles extracted from the live site

Date: May 2026

Auditor: ProvablySmart Research Lab

1. Methodology

Duel.com is a single-page application built with Vue.js 3.5.17 and bundled with Vite. The entire frontend — including all fairness algorithms — is delivered as client-side JavaScript. This means the code that determines game outcomes runs in the player’s browser and is fully inspectable.

1.1 Source Extraction

We identified and downloaded four JavaScript modules containing all fairness logic:

ModuleSizeGames Covered
blackjackFairness-qTk8WM6N.js5.6 KBBlackjack + shared HMAC-SHA256 primitives (hexToBytes, bytesToHex, generateHMAC_SHA256)
videoPokerFairness-Drkyg7jr.js15.2 KBDice, Mines, Keno, Plinko, Cross Road, Video Poker
verify-DuBSsjaY.js60.9 KBCoinflip, Crash, Castle Roulette, Rock Paper Scissors, PF Slots + verification UI
FairnessNextSeed-Bsx8KEeZ.js3.6 KBSeed lifecycle management, rotation, guest mode, localStorage persistence

All modules contain developer-written comments explaining the algorithms. The fairness code is not obfuscated — only the surrounding Vue.js component code is minified by Vite’s production build.

1.2 Analysis Approach

For each game, we verified:

  • Determinism: Does the same input (server seed, client seed, nonce) always produce the same output?
  • Uniformity: Is the output distribution unbiased across the valid range?
  • Independence: Are sequential outputs statistically independent?
  • Commitment: Is the server seed committed (hashed) before the player makes a decision?
  • Verifiability: Can the player independently reproduce the outcome after seed rotation?

2. Architecture Overview

Duel.com employs three distinct fairness models, selected based on the trust topology of each game type. This is unusual — most platforms use a single model for all games.

ModelTrust BasisGamesRandomness Source
A: Seed TripleCommit-reveal of server seed + player-chosen client seedDice, Mines, Keno, Plinko, Blackjack, Video Poker, Cross Road, PF SlotsHMAC-SHA256(serverSeed, clientSeed:nonce:cursor)
B: drand BeaconExternal decentralized randomness (League of Entropy)Coinflip, Crash, Castle RouletteHMAC-SHA256(serverSeed, hexToUtf8(drandSeed):0)
C: Commit-RevealMutual commitment between two playersRock Paper ScissorsSHA-256(choice|clientKey) per player

3. Shared Cryptographic Primitives

3.1 HMAC-SHA256 Implementation

All games ultimately depend on a single HMAC function. Extracted from blackjackFairness:

async function generateHMAC_SHA256(keyHex, message) {
  const keyBytes = hexToBytes(keyHex);

  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const signature = await crypto.subtle.sign('HMAC', cryptoKey, message);
  return bytesToHex(new Uint8Array(signature));
}

Assessment: Correct. Uses the Web Crypto API (crypto.subtle), which is:

  • Implemented in native code within the browser engine (V8/SpiderMonkey/WebKit)
  • Resistant to timing side-channel attacks (constant-time comparison)
  • FIPS 140-2 validated in major browser implementations
  • Not susceptible to the implementation bugs common in JavaScript-only crypto libraries

The key is passed as raw hex bytes (not UTF-8 string), and the message is a Uint8Array. Both are correct for HMAC-SHA256.

3.2 Hex/Byte Conversion

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}

function bytesToHex(bytes) {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

Assessment: Standard implementation. padStart(2, '0') ensures consistent zero-padding for bytes 0x00–0x0F. No edge cases.

3.3 drand Seed Decoding

function hexToUtf8String(publicSeed) {
  const bytes = hexToBytes(publicSeed);
  return new TextDecoder('utf-8').decode(bytes);
}

Assessment: The drand beacon’s randomness value is received as a hex string and decoded to UTF-8 before being used as the HMAC message. This is a design choice — it means the effective entropy comes from the UTF-8 representation of the drand bytes, not the raw bytes themselves. This does not reduce security because the drand output is already cryptographically random and the HMAC operation preserves entropy.

4. Randomness Extraction Methods

After generating an HMAC hash, the platform must convert the 256-bit output into a game-specific result within a bounded range. This is where most provably fair implementations introduce subtle biases. We identified three distinct extraction methods.

4.1 Rejection Sampling (Standard Games)

Used by: Dice, Mines, Keno, Blackjack, Video Poker, Cross Road, PF Slots.

The canonical pattern, from the Dice module:

const MAX_UINT32 = 0xFFFFFFFF; // 4,294,967,295
const RANGE = 10001;
const MAX_FAIR = MAX_UINT32 - (MAX_UINT32 % RANGE);

while (offset + 8 <= hash.length) {
  const value = parseInt(hash.slice(offset, offset + 8), 16);
  if (value < MAX_FAIR) {
    return (value % RANGE) / 100;
  }
  offset += 8;
}

Mathematical analysis:

  • MAX_UINT32 = 2³² − 1 = 4,294,967,295
  • For Dice: RANGE = 10,001, so MAX_FAIR = 4,294,967,295 − (4,294,967,295 mod 10,001) = 4,294,967,295 − 7,294 = 4,294,960,001
  • Rejection probability per 4-byte chunk: 7,295 / 4,294,967,296 ≈ 0.00017%
  • Probability of rejecting all 8 chunks in a SHA-256 hash: (7,295/4,294,967,296)⁸ ≈ 1.24 × 10⁻⁴⁶

Assessment: Correct. This is the standard rejection sampling technique recommended by NIST SP 800-90A for converting uniform random bits into a uniform value in a non-power-of-two range. The rejection probability is negligible, and the hash provides 8 independent 4-byte chunks as fallback.

4.2 Direct Modulo (Binary Ranges)

Used by: Coinflip (% 2), Plinko bounces (% 2).

// Coinflip
const value = parseInt(hash.slice(0, 8), 16);
const coinflipResult = (value % 2) + 1;

// Plinko (per bounce)
position += (value % 2);

Assessment: Correct. Since 2³² is exactly divisible by 2, value % 2 has zero modulo bias. No rejection sampling is needed. This is optimal.

4.3 Direct Modulo (Non-Binary Ranges Without Rejection)

Used by: Castle Roulette (% 48).

const RANGE = 48;
const value = parseInt(hash.slice(0, 8), 16);
return (value % RANGE).toString();

Assessment: This is the one exception. 2³² mod 48 = 16, which means values 0–15 have a probability of ⌈2³²/48⌉/2³² = 89,478,486/4,294,967,296 while values 16–47 have a probability of ⌊2³²/48⌋/2³² = 89,478,485/4,294,967,296. The bias is:

|P(k) − 1/48| = 1/4,294,967,296 ≈ 2.33 × 10⁻¹⁰ for the 16 favored values.

This is approximately 0.000000023% bias per outcome. Over 1 billion rounds, this would produce approximately 0.23 extra hits on favored positions — statistically undetectable in practice. However, it is a deviation from the otherwise perfect bias-elimination pattern across all other games.

Recommendation: Apply rejection sampling for consistency. The performance cost is negligible.

5. Permutation Algorithms

5.1 Fisher-Yates Shuffle

Used by: Mines (25 positions), Keno (40 positions), Video Poker (52 cards), Cross Road (variable grid).

All four games use an identical algorithmic pattern. Extracted from the Mines module:

const positions = Array.from({ length: gridSize }, (_, i) => i);

for (let i = gridSize - 1; i > 0; i--) {
  const range = i + 1;
  const maxFair = MAX_UINT32 - (MAX_UINT32 % range);
  let cursor = gridSize - 1 - i;

  while (true) {
    const hash = await generateHMAC_SHA256(
      serverSeedHex,
      new TextEncoder().encode(`${clientSeed}:${nonce}:${cursor}`)
    );

    let found = false;
    for (let off = 0; off + 8 <= hash.length; off += 8) {
      const value = parseInt(hash.slice(off, off + 8), 16);
      if (value < maxFair) {
        const j = value % range;
        [positions[i], positions[j]] = [positions[j], positions[i]];
        found = true;
        break;
      }
    }

    if (found) break;
    cursor++;
  }
}

return positions.slice(0, minesCount).sort((a, b) => a - b);

Properties verified:

  • Correct direction: The loop iterates from gridSize - 1 down to 1 (Durstenfeld variant of Fisher-Yates). This is correct — iterating upward would produce a Sattolo cycle (not a uniform permutation).
  • Correct swap range: At iteration i, the swap index j is chosen uniformly from [0, i] (inclusive). This produces each of the n! possible permutations with equal probability.
  • Independent entropy per swap: Each swap uses its own HMAC hash via a unique cursor value. This prevents correlation between swap decisions — a critical property that many implementations get wrong by reusing bytes from a single hash.
  • Rejection sampling per swap: Each swap independently applies rejection sampling with a threshold appropriate for its range (i + 1). This maintains uniformity even as the range shrinks during iteration.
  • Cursor increment on rejection: If all 8 chunks of a hash are rejected (astronomically unlikely), the cursor increments and a new hash is generated. This prevents deadlocks.

Assessment: This is a textbook-correct implementation. The combination of Fisher-Yates with per-swap HMAC entropy and rejection sampling produces a cryptographically uniform permutation.

5.2 Rejection Sampling per Card (Blackjack)

Unlike the shuffle-based games, Blackjack generates cards independently rather than from a shuffled deck:

function generateBlackjackCard(hashHex) {
  const hashBytes = hexToBytes(hashHex);

  for (let i = 0; i <= hashBytes.length - 4; i += 4) {
    const view = new DataView(hashBytes.buffer, hashBytes.byteOffset + i, 4);
    const value = view.getUint32(0);
    const max = 52 * Math.floor(0x100000000 / 52);
    if (value < max) {
      return CARDS[value % 52];
    }
  }

  throw new Error('Failed to generate unbiased card value from hash');
}

async function getCard(serverSeed, clientSeed, nonce, cursor) {
  const message = new TextEncoder().encode(`${clientSeed}:${nonce}:${cursor}`);
  const hash = await generateHMAC_SHA256(serverSeed, message);
  return generateBlackjackCard(hash);
}

Note: This uses DataView.getUint32(0) (big-endian) instead of parseInt(hex, 16). The result is identical — both extract a 32-bit unsigned integer from 4 bytes — but the DataView approach is slightly more performant as it avoids string operations.

Implication: Cards are drawn with replacement from a 52-card set. This means the same card can appear multiple times in a single round. This is different from a physical shoe where cards are dealt without replacement. The platform generates up to 50 cards per round (cursors 0–49) to handle splitting and complex multi-hand scenarios.

Assessment: Correct for the stated model (infinite deck). The rejection threshold 52 × ⌊2³²/52⌋ = 52 × 82,595,524 = 4,294,967,248 produces zero bias. Rejection probability is 48/2³² ≈ 0.0000011% per chunk.

6. PCG32 PRNG Analysis (PF Slots)

Slots use a fundamentally different architecture than other games. Instead of generating HMAC hashes per random value, slots seed a deterministic PRNG and draw all values from it.

6.1 PRNG Implementation

class Pcg32 {
  constructor(seed32) {
    this.increment = 0x5851f42d4c957f2dn;
    let seed64 = BigInt(seed32 >>> 0);

    // SplitMix64-style seed expansion
    seed64 = (seed64 + 0x9e3779b97f4a7c15n) & 0xFFFFFFFFFFFFFFFFn;
    seed64 = ((seed64 ^ (seed64 >> 30n)) * 0xbf58476d1ce4e5b9n) & 0xFFFFFFFFFFFFFFFFn;
    seed64 = ((seed64 ^ (seed64 >> 27n)) * 0x94d049bb133111ebn) & 0xFFFFFFFFFFFFFFFFn;
    seed64 ^= (seed64 >> 31n);

    this.state = seed64;
  }

  nextUint32() {
    const oldState = this.state;
    this.state = (oldState * 6364136223846793005n + this.increment) & 0xFFFFFFFFFFFFFFFFn;

    const xorShifted = Number(((oldState >> 18n) ^ oldState) >> 27n) >>> 0;
    const rot = Number(oldState >> 59n) & 31;

    return ((xorShifted >>> rot) | (xorShifted << ((32 - rot) & 31))) >>> 0;
  }
}

Analysis:

  • Algorithm: PCG-XSH-RR (PCG family, XOR-shift high, random rotation). Published by Melissa O'Neill (2014). The increment 0x5851f42d4c957f2d is hardcoded (not configurable), which is acceptable — it must be odd, and this value is taken from O'Neill's reference implementation.
  • Seed expansion: Uses SplitMix64 constants (Steele, Lea, Wolf 2014) to expand a 32-bit seed into a 64-bit state. This is a common technique for converting low-entropy seeds into well-distributed initial states.
  • Period: 2⁶⁴ ≈ 1.84 × 10¹⁹ values before cycling. More than sufficient for any slot spin.
  • Statistical quality: PCG32 passes all tests in TestU01's BigCrush battery (the most stringent known statistical test suite for PRNGs).

Assessment: Sound choice. PCG32 is a well-studied, high-quality PRNG appropriate for this use case. The determinism property (same seed → same output) is what makes verification possible.

6.2 Seed Derivation Pipeline

// Step 1: HMAC → 32-bit outcome index
async function generateOutcomeIndex(serverSeed, clientSeed, nonce) {
  const message = clientSeedHex + ':' + nonce;
  const signature = await crypto.subtle.sign('HMAC', key, encode(message));
  return new DataView(signature).getUint32(0, false); // big-endian
}

// Step 2: Mix outcome index with round/tumble indices
function deriveTumbleSeed(outcomeIndex, roundIndex, tumbleIndex) {
  let x = outcomeIndex >>> 0;
  x ^= Math.imul(roundIndex + 1, 0x9e3779b9) >>> 0;  // golden ratio
  x ^= Math.imul(tumbleIndex + 1, 0x85ebca6b) >>> 0;  // MurmurHash3 constant
  x ^= x >>> 16;
  x = Math.imul(x, 0xc2b2ae35) >>> 0;                 // finalizer
  x ^= x >>> 16;
  return x >>> 0;
}

Analysis: The tumble seed derivation uses integer mixing with well-known hash constants (golden ratio 0x9e3779b9, MurmurHash3 finalizer constants). The +1 offset on indices ensures that round 0 and tumble 0 don't zero out the XOR. The final xmxm pattern (xor-shift, multiply, xor-shift, multiply, xor-shift) is a standard avalanche technique.

Assessment: Correct. Each unique combination of (outcomeIndex, roundIndex, tumbleIndex) produces a statistically independent PCG32 seed, enabling deterministic verification of cascading/tumbling slot mechanics.

6.3 Weighted Symbol Selection

function generateGridFromPrng(prng, reels, rows, settings) {
  for (let col = 0; col < reels; col++) {
    const weights = getWeightsForReel(col, settings);
    const totalWeight = weights.reduce((sum, w) => sum + w, 0);
    const maxFair = MAX_UINT32 - (MAX_UINT32 % totalWeight);

    for (let row = 0; row < rows; row++) {
      let rnd;
      do {
        rnd = prng.nextUint32();
      } while (rnd >= maxFair);

      const pick = rnd % totalWeight;
      let cumulativeWeight = 0;
      for (let i = 0; i < weights.length; i++) {
        cumulativeWeight += weights[i];
        if (pick < cumulativeWeight) {
          symbolIndex = i;
          break;
        }
      }
      reel.push(symbolIndex);
    }
  }
}

Assessment: Correct. Rejection sampling is applied even within the PCG32 output stream. The cumulative weight lookup is a standard technique for weighted discrete sampling.

Trust boundary note

The symbolWeights and reelConfigs arrays are provided by the server API, not embedded in client code. This means: the random number generation is verifiable, but the weight configuration requires trust. A player cannot independently verify that the advertised symbol weights match the actual weights used during gameplay without collecting a large statistical sample and performing chi-squared goodness-of-fit testing.

This is an inherent limitation of weighted slot systems, not a Duel-specific issue. It applies to all provably fair slot implementations.

7. Multiplayer Fairness: drand Beacon Integration

7.1 Why drand?

In standard provably fair (Model A), the player contributes a client seed that influences the outcome. This requires a per-player seed exchange before each round — impractical for multiplayer games like Crash or Roulette where all participants share the same outcome.

drand (Distributed Randomness Beacon) solves this by providing a publicly verifiable, tamper-proof source of randomness from an external network. The League of Entropy consortium includes Cloudflare, Protocol Labs, EPFL, University of Chile, and others. Each drand round is:

  • Generated by threshold BLS signatures across multiple independent nodes
  • Published at deterministic intervals (every 3 seconds on mainnet)
  • Verifiable by anyone using the chain's public key
  • Unpredictable before publication (information-theoretically secure threshold scheme)

7.2 Crash Implementation

const validateCrashResult = async (serverSeed, drandSeed) => {
  const NONCE = 0;
  const randomness = hexToUtf8String(drandSeed);
  const message = new TextEncoder().encode(`${randomness}:${NONCE}`);
  const hash = await generateHMAC_SHA256(serverSeed, message);

  const value = parseInt(hash.slice(0, 8), 16);

  const MAX = 2 ** 32;
  const houseEdge = 0.001;
  const result = (MAX / (value + 1)) * (1 - houseEdge);

  return Math.max(1.0, result);
};

Mathematical analysis of the crash distribution:

  • Let V be the 32-bit hash value, uniformly distributed in [0, 2³² − 1]
  • Crash point: C = (2³² / (V + 1)) × 0.999
  • The probability of crashing at or below multiplier m is: P(C ≤ m) = P(V ≥ 2³² × 0.999 / m − 1) = 1 − (0.999 / m) for m ≥ 0.999
  • Instant crash (C = 1.00x) probability: P(V ≥ 2³² × 0.999 − 1) ≈ 0.1%
  • Expected value of betting 1 unit at auto-cashout m: EV = m × (0.999/m) − 1 = 0.999 − 1 = −0.001

This confirms a flat 0.1% house edge regardless of cashout strategy. The Math.max(1.0, result) clamps the minimum multiplier, creating the "instant crash" scenario when the hash value is very large.

7.3 Coinflip Implementation

const NONCE = 0;
const randomness = hexToUtf8String(drandSeed);
const message = new TextEncoder().encode(`${randomness}:${NONCE}`);
const hash = await generateHMAC_SHA256(serverSeed, message);
const value = parseInt(hash.slice(0, 8), 16);
const coinflipResult = (value % 2) + 1;

Assessment: 2³² mod 2 = 0. Zero bias. Result is 1 (Crown) or 2 (Swords) with exactly 50/50 probability. The serverSeed is combined with the drand beacon via HMAC, ensuring neither the casino nor the beacon alone determines the outcome.

7.4 Castle Roulette Implementation

const RANGE = 48;
const value = parseInt(hash.slice(0, 8), 16);
return (value % RANGE).toString();

Assessment: As analyzed in Section 4.3, this has a negligible bias of ~2.33 × 10⁻¹⁰ per outcome. The absence of rejection sampling here is inconsistent with the rest of the codebase.

8. PvP Fairness: Commit-Reveal (Rock Paper Scissors)

const CHOICES = ['rock', 'paper', 'scissors'];

async function generateCommitHash(choice, clientKey) {
  const message = `${choice}|${clientKey}`;
  const messageData = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest('SHA-256', messageData);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}

async function findChoiceForHash(clientKey, commitHash) {
  for (const choice of CHOICES) {
    const hash = await generateCommitHash(choice, clientKey);
    if (hash === commitHash) return choice;
  }
  return null;
}

Security analysis:

  • Binding property: A player cannot find a different (choice, clientKey) pair that produces the same commit hash. This requires finding a SHA-256 collision, which is computationally infeasible (2¹²⁸ operations for birthday attack).
  • Hiding property: The commit hash does not reveal the choice — provided the clientKey has sufficient entropy. With a random 16-character alphanumeric key, there are 62¹⁶ ≈ 4.77 × 10²⁸ possible keys, making brute-force preimage search infeasible.
  • Verification: After reveal, verification requires only 3 SHA-256 computations (one per possible choice). This is instant.

Assessment: Correct implementation of the commit-reveal paradigm. The server acts only as a mediator — it cannot influence or predict outcomes.

9. Seed Lifecycle Management

9.1 Logged-In Users

Server-side seed management via API. The flow:

  1. Server generates a random serverSeed and stores it securely
  2. SHA-256(serverSeed) is displayed to the player as server_seed_hashed
  3. Player sets their client_seed (default: random 16-char alphanumeric)
  4. Each bet increments the nonce
  5. On seed rotation: old serverSeed is revealed, new serverSeed is generated and hashed, nonce resets to 0

9.2 Guest Users (Browser-Local Seeds)

From FairnessNextSeed.vue:

const guestServerSeed = useLocalStorage('duel:guest_server_seed', generateRandom(64));
const guestClientSeed = useLocalStorage('duel:guest_client_seed', generateRandom(16));
const guestNonce = useLocalStorage('duel:guest_nonce', 0);
const guestPreviousServerSeed = useLocalStorage('duel:guest_previous_server_seed', '');

function hashSeedToHex(seed) {
  const hasher = new jsSHA('SHA-256', 'TEXT');
  hasher.update(seed);
  return hasher.getHash('HEX');
}

function rotateGuestSeeds(newClientSeed) {
  previousServerSeed.value = serverSeed.value;
  serverSeed.value = nextServerSeed.value;
  clientSeed.value = newClientSeed;
  nonce.value = 0;
  nextServerSeed.value = generateRandom(64);
  return { success: true };
}

Assessment: Guest seeds are generated and stored entirely in the browser's localStorage. The hashing uses jsSHA (a well-known JavaScript SHA library) rather than Web Crypto API — likely because crypto.subtle.digest is async and the hashing here is done synchronously for UI responsiveness. This is acceptable; jsSHA is well-tested and SHA-256 is not timing-sensitive for this use case (the input is the platform's own seed, not a secret).

Limitation: Guest seeds persist in localStorage. If the user clears browser data, all verification history is lost. There is no export/backup mechanism visible in the code.

9.3 Active Game Protection

// Games can register themselves as "active" to prevent mid-game seed rotation
function registerActiveGame(gameName, isActiveCallback) {
  activeGames.set(isActiveCallback, gameName);
  return () => activeGames.delete(isActiveCallback);
}

// Before rotation, check if any game is in progress
function rotateGuestSeeds(newClientSeed) {
  for (const [callback, gameName] of activeGames) {
    if (callback()) {
      return { success: false, gameName };
    }
  }
  // ... proceed with rotation
}

Assessment: Good defensive design. Prevents seed rotation during an active game, which would invalidate in-progress verification. Games register a callback that returns true if a round is in progress.

10. Complete Game-by-Game Summary

GameModelEntropy SourceExtractionRangeBias
DiceAHMAC(ss, cs:n)Rejection sampling0–10000Zero
MinesAHMAC(ss, cs:n:c)Fisher-Yates + RS25 positionsZero
KenoAHMAC(ss, cs:n:c)Fisher-Yates + RS40 positions → 10Zero
PlinkoAHMAC(ss, cs:n:c)Direct modulo{0, 1} per rowZero
BlackjackAHMAC(ss, cs:n:c)Rejection sampling0–51 (52 cards)Zero
Video PokerAHMAC(ss, cs:n:c)Fisher-Yates + RS52 cardsZero
Cross RoadAHMAC(ss, cs:n:c)Fisher-Yates + RSVariable gridZero
PF SlotsAHMAC → PCG32Weighted RS from PRNGPer-reel weightsZero
CoinflipBHMAC(ss, drand:0)Direct modulo{1, 2}Zero
CrashBHMAC(ss, drand:0)Inverse transform[1.0, ∞)Zero*
Castle RouletteBHMAC(ss, drand:0)Direct modulo0–47 (48 slots)~2.3×10⁻¹⁰
Rock Paper ScissorsCSHA-256(choice|key)Commit-reveal{R, P, S}Zero

* Crash applies a 0.1% house edge via multiplicative factor 0.999. This is transparent, not a bias in the RNG.

Key: ss = serverSeed, cs = clientSeed, n = nonce, c = cursor, RS = rejection sampling

11. Findings

11.1 Strengths

  • S1 — Correct rejection sampling: Applied consistently across all games requiring non-power-of-two ranges (11 of 12 games). Eliminates modulo bias to exactly zero.
  • S2 — Correct Fisher-Yates implementation: Backward iteration (Durstenfeld variant), correct swap range [0, i], independent HMAC entropy per swap. Produces uniform permutations.
  • S3 — Web Crypto API usage: All HMAC-SHA256 and SHA-256 operations use the browser's native crypto.subtle API. No custom cryptographic implementations.
  • S4 — Multi-model architecture: Three distinct fairness models matched to game trust topology. drand beacon for multiplayer is a stronger trust guarantee than standard server-seed-only systems.
  • S5 — Transparent code: Fairness algorithms include developer-written comments explaining the logic. No obfuscation of fairness-critical code paths.
  • S6 — Per-cursor entropy: Games requiring multiple random values per round use unique HMAC hashes via cursor incrementation, preventing correlation between values.
  • S7 — PCG32 for slots: A well-studied, statistically robust PRNG seeded from HMAC output. Passes BigCrush. Deterministic for verification.
  • S8 — Explicit house edge: The Crash game's 0.1% house edge is hardcoded as a named constant (const houseEdge = 0.001), not hidden in opaque arithmetic.
  • S9 — Active game protection: Seed rotation is blocked during in-progress rounds, preventing accidental verification invalidation.

11.2 Weaknesses

  • W1 — Castle Roulette modulo bias: Uses value % 48 without rejection sampling. Bias is ~2.33 × 10⁻¹⁰ per outcome — negligible in practice but inconsistent with the otherwise rigorous bias elimination. Severity: Low.
  • W2 — No server seed hash chain: Each server seed is independent. A hash chain (where each server seed is the hash of the next) would allow players to verify that the casino committed to a sequence of seeds in advance, preventing selective seed generation. Severity: Medium (architectural, not a vulnerability).
  • W3 — Slot weights are server-provided: The symbolWeights and reelConfigs for PF Slots come from the server API. The RNG is verifiable, but the weight configuration is a trust point. Severity: Medium (inherent to weighted slot systems).
  • W4 — Blackjack uses sampling with replacement: Cards are drawn independently from a 52-card set, allowing duplicates. This is mathematically valid but differs from physical dealing and is not prominently documented. Severity: Informational.
  • W5 — Guest seed backup: Guest mode seeds in localStorage have no export mechanism. Browser data clearing permanently destroys verification capability for past bets. Severity: Low.

11.3 Recommendations

IDRecommendationPriority
R1Add rejection sampling to Castle Roulette (maxFair = MAX_UINT32 - (MAX_UINT32 % 48)) for consistencyLow
R2Implement a server seed hash chain to strengthen forward-commitment guaranteesMedium
R3Publish slot symbol weight tables in documentation or source code for independent verificationMedium
R4Document the Blackjack with-replacement model explicitly in the fairness UILow
R5Add seed export functionality for guest accountsLow

12. Conclusion

Duel.com's provably fair system demonstrates a level of cryptographic engineering that exceeds the industry standard for crypto casinos. The code is clean, correctly structured, and uses established primitives (HMAC-SHA256 via Web Crypto API, Fisher-Yates shuffle, PCG32 PRNG, drand beacon) in their canonical forms.

The identified weaknesses (W1–W5) range from negligible to architectural, and none represent exploitable vulnerabilities. The most impactful improvement would be implementing a server seed hash chain (R2) to further reduce the trust surface.

This audit covers only the client-side fairness algorithms. Server-side seed generation quality (CSPRNG usage, entropy sources) and operational security (key storage, access controls, deployment integrity) are outside the scope of this analysis and would require a separate infrastructure audit.

Disclosure

This audit was conducted independently by ProvablySmart Research Lab. The platform creator granted permission for source code analysis and publication. No compensation was received. No affiliate or commercial relationship exists between ProvablySmart and Duel.com. This document is provided for educational and research purposes only. It is not financial advice, not a gambling recommendation, and not an endorsement of any platform.

Technical FAQ

Can Duel.com predict or manipulate outcomes in their Originals games?

For Model A (solo) games: the server seed is committed via SHA-256 hash before the player bets. Changing the seed would change the hash, which the player can detect. For Model B (multiplayer) games: the randomness comes from the drand beacon, which neither the casino nor any single party controls. For Model C (PvP): the server is not involved in outcome determination — both players commit hashes independently. In all three models, manipulation would require either breaking SHA-256 preimage resistance (computationally infeasible) or compromising the drand network (requires corrupting a threshold number of independent operators).

What is rejection sampling and why does it matter?

When converting a uniform random 32-bit integer into a value within a range R that does not evenly divide 2³², the naive approach (value % R) gives slightly higher probability to values 0 through (2³² mod R) − 1. Rejection sampling discards values ≥ R × ⌊2³²/R⌋ and redraws, producing a perfectly uniform distribution. The bias from naive modulo is small (typically < 0.001%), but it is unnecessary and avoidable. Duel.com's implementation uses rejection sampling in all applicable games except Castle Roulette.

Why does Duel use PCG32 for slots instead of HMAC-SHA256?

A single slot spin may require 15–30+ random values (one per cell in a 5×3 or 6×4 grid, plus multiplier selections). Generating a separate HMAC-SHA256 hash for each value would be computationally expensive and create very long verification code. PCG32 is a deterministic PRNG that produces statistically excellent output and can generate unlimited values from a single 32-bit seed. The seed is derived from HMAC-SHA256, so the cryptographic commitment chain is preserved.

What is a hash chain and why is its absence noted as a weakness?

A hash chain is a structure where serverSeed[n] = SHA-256(serverSeed[n+1]). The casino generates seeds in reverse order and reveals them forward. This proves the entire seed sequence was committed before any gameplay began. Without a hash chain, the casino could theoretically generate many seed candidates and selectively use unfavorable ones (though the player's client seed still prevents this if it has sufficient entropy). A hash chain adds defense-in-depth.

How can I independently verify a Duel.com bet?

After rotating your seed pair: (1) obtain the revealed serverSeed, your clientSeed, and the nonce for the bet; (2) open your browser's developer console (F12); (3) in the Duel.com fairness modal, click "Copy Code" to get the game-specific verification script; (4) paste and run it in the console. The script will compute the outcome from your inputs using the same algorithm analyzed in this audit. Compare the computed result to the actual result you experienced.

Does this audit guarantee Duel.com is safe to use?

No. This audit covers only the mathematical correctness of client-side fairness algorithms. It does not cover: server-side implementation fidelity (whether the server actually uses the committed seeds), operational security, financial solvency, regulatory compliance, withdrawal reliability, or any other aspect of the platform's trustworthiness. A provably fair system guarantees outcome verifiability — nothing more.