Duel.com Provably Fair Audit: Full Source Code Analysis of 12 In-House Games
Treat this document as a cryptographic teardown, nothing else. You will find no casino review here, no recommendation to play, and no affiliate linkage of any kind. There is a single question on the table — “does this platform’s provably fair system actually work?” — and everything below exists to answer it with evidence rather than opinion.
Our process was straightforward: pull every fairness-related JavaScript module out of Duel.com’s production frontend, decompile the algorithms inside them, and check the cryptography against established standards. Monarch, Duel.com’s creator, explicitly authorized us to publish what we found.
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 ships as a single-page application built on Vue.js 3.5.17 and bundled by Vite. Because every fairness algorithm runs client-side in your browser, all of the outcome logic is delivered as plain JavaScript — inspectable by anyone who opens devtools. No black box required.
1.1 Source Extraction
Four JavaScript modules turned out to contain the complete fairness logic:
| Module | Size | Games Covered |
|---|---|---|
blackjackFairness-qTk8WM6N.js | 5.6 KB | Blackjack + shared HMAC-SHA256 primitives (hexToBytes, bytesToHex, generateHMAC_SHA256) |
videoPokerFairness-Drkyg7jr.js | 15.2 KB | Dice, Mines, Keno, Plinko, Cross Road, Video Poker |
verify-DuBSsjaY.js | 60.9 KB | Coinflip, Crash, Castle Roulette, Rock Paper Scissors, PF Slots + verification UI |
FairnessNextSeed-Bsx8KEeZ.js | 3.6 KB | Seed lifecycle management, rotation, guest mode, localStorage persistence |
The fairness routines ship with developer-written comments intact. Only the surrounding Vue component code gets minified by Vite’s production build — the algorithms themselves are left readable.
1.2 Analysis Approach
Every game was checked against five properties:
- 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
Rather than forcing one fairness model onto every title, Duel.com runs three, chosen per game type according to who has to trust whom. Most platforms keep it simple and use one model everywhere; this split is uncommon.
| Model | Trust Basis | Games | Randomness Source |
|---|---|---|---|
| A: Seed Triple | Commit-reveal of server seed + player-chosen client seed | Dice, Mines, Keno, Plinko, Blackjack, Video Poker, Cross Road, PF Slots | HMAC-SHA256(serverSeed, clientSeed:nonce:cursor) |
| B: drand Beacon | External decentralized randomness (League of Entropy) | Coinflip, Crash, Castle Roulette | HMAC-SHA256(serverSeed, hexToUtf8(drandSeed):0) |
| C: Commit-Reveal | Mutual commitment between two players | Rock Paper Scissors | SHA-256(choice|clientKey) per player |
3. Shared Cryptographic Primitives
3.1 HMAC-SHA256 Implementation
Everything funnels through one HMAC function. Here it is, straight 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. It delegates to the Web Crypto API (crypto.subtle), which brings real advantages over hand-rolled JavaScript crypto:
- 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
Type handling is right too: raw hex bytes for the key rather than a UTF-8 string, and a Uint8Array for the message.
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: A bog-standard conversion pair. The padStart(2, '0') call guarantees correct zero-padding for values 0x00–0x0F. We found no edge cases worth flagging.
3.3 drand Seed Decoding
function hexToUtf8String(publicSeed) {
const bytes = hexToBytes(publicSeed);
return new TextDecoder('utf-8').decode(bytes);
}Assessment: Before the drand beacon’s randomness value enters the HMAC as a message, its hex string is decoded into UTF-8. That choice means effective entropy flows from the UTF-8 representation of the drand bytes rather than the raw bytes — an aesthetic difference, not a security one. The beacon output is already cryptographically random, and HMAC preserves entropy regardless.
4. Randomness Extraction Methods
An HMAC digest alone isn’t a game result. The 256-bit output still has to be squeezed into a bounded range, and that squeeze step is where sloppy implementations quietly introduce bias. Duel.com ships 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;
}The math, worked out:
MAX_UINT32 = 2³² − 1 = 4,294,967,295- For Dice:
RANGE = 10,001, soMAX_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 matches the rejection sampling approach NIST SP 800-90A recommends for turning uniform random bits into a uniform value in a non-power-of-two range. With a rejection probability this small plus eight independent 4-byte fallback chunks per hash, exhausted entropy simply isn't a practical concern.
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. Because 2³² divides evenly by 2, value % 2 carries zero modulo bias and needs no rejection sampling at all. This is the cleanest possible approach for a binary outcome.
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: One exception lives here. Since 2³² mod 48 = 16, values 0–15 occur with probability ⌈2³²/48⌉/2³² = 89,478,486/4,294,967,296, while values 16–47 land at ⌊2³²/48⌋/2³² = 89,478,485/4,294,967,296. Per favored outcome, the excess is:
|P(k) − 1/48| = 1/4,294,967,296 ≈ 2.33 × 10⁻¹⁰
In concrete terms, that's roughly 0.000000023% bias per outcome — about 0.23 extra hits on favored positions across a billion rounds, invisible to any statistical test you could realistically run. Still, it stands out precisely because the other eleven games eliminate bias so rigorously.
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 share one algorithmic skeleton. This copy comes 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);What we verified:
- Correct direction: The loop iterates from
gridSize - 1down to1(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 indexjis chosen uniformly from[0, i](inclusive). This produces each of then!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: Textbook execution. Fisher-Yates paired with per-swap HMAC entropy and rejection sampling yields a cryptographically uniform permutation — no shortcuts taken.
5.2 Rejection Sampling per Card (Blackjack)
Blackjack breaks ranks with the shuffle-based titles: instead of shuffling a deck up front, it draws each card independently:
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. Duplicates within a round are therefore possible, unlike dealing from a physical shoe without replacement. To cover splits and complicated multi-hand situations, the platform reserves up to 50 cursors (0–49) per round.
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 march to their own architecture. Rather than computing one HMAC hash per random draw, they seed a deterministic PRNG once and pull every subsequent value from that stream.
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;
}
}Point by point:
- Algorithm: PCG-XSH-RR (PCG family, XOR-shift high, random rotation). Published by Melissa O'Neill (2014). The increment
0x5851f42d4c957f2dis 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: Solid engineering pick. PCG32 is well studied, statistically strong, and fits this use case neatly. Its determinism — same seed in, same values out — is the very property that makes post-hoc verification workable.
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;
}How it works: The tumble seed derivation relies on integer mixing with widely recognized constants — the golden ratio (0x9e3779b9) plus MurmurHash3 finalizer values. Each index carries a +1 offset so that round 0 and tumble 0 can't annihilate the XOR entirely. Closing things out, the xor-multiply-xor-xor-multiply-xor pattern delivers proper avalanche behavior.
Assessment: Correct. Every distinct (outcomeIndex, roundIndex, tumbleIndex) triple maps to a statistically independent PCG32 seed, which is what allows cascading/tumbling slot mechanics to be verified deterministically.
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. Even inside the PCG32 output stream, rejection sampling stays in place. Cumulative-weight lookup is the standard mechanism for weighted discrete sampling.
Trust boundary note
Here's the catch: the symbolWeights and reelConfigs arrays arrive from the server API rather than living in the client bundle. Verifiable therefore means the random number generation — not the weight configuration. To confirm that the advertised symbol weights match those actually applied in play, a player would need to gather a large statistical sample and run 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?
Model A depends on the player contributing a client seed before each round — fine for solo play, hopeless for shared-outcome multiplayer rounds like Crash or Roulette where collecting seeds from every participant mid-round simply can't happen fast enough.
The answer is drand (Distributed Randomness Beacon): publicly verifiable randomness from outside the platform, produced by the League of Entropy consortium whose members include Cloudflare, Protocol Labs, EPFL, University of Chile, and others. For every drand round:
- 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);
};Distribution mathematics, examined:
- Let
Vbe 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
mis:P(C ≤ m) = P(V ≥ 2³² × 0.999 / m − 1) = 1 − (0.999 / m)form ≥ 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
Bottom line: a flat 0.1% house edge, independent of any cashout strategy chosen. 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. Neither party alone dictates the flip: combining serverSeed with the beacon via HMAC means the casino can't rig it and neither can the network acting independently.
7.4 Castle Roulette Implementation
const RANGE = 48;
const value = parseInt(hash.slice(0, 8), 16);
return (value % RANGE).toString();Assessment: Section 4.3 already covers the arithmetic: ~2.33 × 10⁻¹⁰ bias per outcome, negligible in absolute terms yet inconsistent with the rejection sampling discipline used elsewhere in this 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: A faithful implementation of the commit-reveal paradigm. Positioning the server purely as a mediator removes it from the outcome path entirely — influence or prediction are structurally impossible.
9. Seed Lifecycle Management
9.1 Logged-In Users
Registered accounts manage seeds through the API, in five steps:
- Server generates a random serverSeed and stores it securely
- SHA-256(serverSeed) is displayed to the player as
server_seed_hashed - Player sets their
client_seed(default: random 16-char alphanumeric) - Each bet increments the
nonce - 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 never leave the browser — generation and storage happen entirely in localStorage. Notably, hashing goes through jsSHA, a well-known JavaScript SHA library, instead of Web Crypto API. The likely reason: crypto.subtle.digest is asynchronous, whereas UI responsiveness demands synchronous hashing here. Acceptable either way — jsSHA is battle-tested, and SHA-256 isn't timing-sensitive in this context since the input is the platform's own seed rather than 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: Thoughtful defensive design. Rotation is refused whenever a game reports itself in progress via callback, keeping live verification valid. Without this guard, rotating mid-round would silently break reproducibility for bets in flight.
10. Complete Game-by-Game Summary
| Game | Model | Entropy Source | Extraction | Range | Bias |
|---|---|---|---|---|---|
| Dice | A | HMAC(ss, cs:n) | Rejection sampling | 0–10000 | Zero |
| Mines | A | HMAC(ss, cs:n:c) | Fisher-Yates + RS | 25 positions | Zero |
| Keno | A | HMAC(ss, cs:n:c) | Fisher-Yates + RS | 40 positions → 10 | Zero |
| Plinko | A | HMAC(ss, cs:n:c) | Direct modulo | {0, 1} per row | Zero |
| Blackjack | A | HMAC(ss, cs:n:c) | Rejection sampling | 0–51 (52 cards) | Zero |
| Video Poker | A | HMAC(ss, cs:n:c) | Fisher-Yates + RS | 52 cards | Zero |
| Cross Road | A | HMAC(ss, cs:n:c) | Fisher-Yates + RS | Variable grid | Zero |
| PF Slots | A | HMAC → PCG32 | Weighted RS from PRNG | Per-reel weights | Zero |
| Coinflip | B | HMAC(ss, drand:0) | Direct modulo | {1, 2} | Zero |
| Crash | B | HMAC(ss, drand:0) | Inverse transform | [1.0, ∞) | Zero* |
| Castle Roulette | B | HMAC(ss, drand:0) | Direct modulo | 0–47 (48 slots) | ~2.3×10⁻¹⁰ |
| Rock Paper Scissors | C | SHA-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.subtleAPI. 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 % 48without 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
symbolWeightsandreelConfigsfor 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
localStoragehave no export mechanism. Browser data clearing permanently destroys verification capability for past bets. Severity: Low.
11.3 Recommendations
| ID | Recommendation | Priority |
|---|---|---|
| R1 | Add rejection sampling to Castle Roulette (maxFair = MAX_UINT32 - (MAX_UINT32 % 48)) for consistency | Low |
| R2 | Implement a server seed hash chain to strengthen forward-commitment guarantees | Medium |
| R3 | Publish slot symbol weight tables in documentation or source code for independent verification | Medium |
| R4 | Document the Blackjack with-replacement model explicitly in the fairness UI | Low |
| R5 | Add seed export functionality for guest accounts | Low |
12. Conclusion
Cryptographic craft like this sits above the norm for crypto casinos. Duel.com's provably fair system leans on established primitives — HMAC-SHA256 via Web Crypto API, Fisher-Yates shuffle, PCG32 PRNG, drand beacon — and deploys each in canonical form. The code reads cleanly, structures logically, and avoids invention where convention suffices.
The weaknesses catalogued above span from practically invisible (the Castle Roulette bias) to architectural (absent hash chaining), and none amount to an exploitable vulnerability. If only one improvement lands, make it R2: a server seed hash chain shrinks the remaining trust surface meaningfully.
Scope reminder: this audit examines client-side fairness algorithms only. 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.



