Skip to Content
Provably FairEC-VRF mode

EC-VRF mode

EC-VRF is an operator option that replaces the hash commitment with a verifiable random function. The rest of the chain, bytes to floats to game event, is unchanged. What changes is where the bytes come from and when you can check them:

Default (hash commitment)EC-VRF
CommitmentHash of a secret server seedA public key (Ed25519)
What the server revealsThe old server seed, at rotationNothing secret: a proof with every bet
When you can verify a betAfter you rotate the seed pairRight away
Secret keyRevealed when the pair is retiredNever revealed

How it works

  1. Key pair. The server holds an Ed25519 key pair. The VRF public key is shown to you (and the next one too) in the Provably Fair modal, instead of a hashed server seed.

  2. Input. For each bet the VRF input, called alpha, is built from your seed and the nonce:

    alpha = UTF-8(`${clientSeed}:${nonce}`)
  3. Prove. The server evaluates the VRF with its private key and returns two values with the bet:

    • a VRF proof of 80 bytes: Gamma (32) + c (16) + s (32), 160 hex characters;
    • a VRF output of 64 bytes, a SHA-512 hash, 128 hex characters.

    The output is determined by the key and alpha: for a given public key and input there is exactly one valid output, and the server cannot choose another.

  4. Bytes. The 64 output bytes are the random bytes:

    RoundBytes
    0Output bytes 0 to 31
    1Output bytes 32 to 63
    2 and aboveHMAC-SHA256 keyed by the 64 output bytes, over the message clientSeed:nonce:round

    The key of that HMAC is the raw output bytes, not their hex text. The floats are then built from four bytes each, and the games map them exactly as described in Game events.

The proof is what makes this verifiable: given only the public key, alpha and the proof, anyone can check that the output is the one the key holder’s VRF produces, without any secret.

What you see

In the Provably Fair modal, the Seeds tab shows the VRF Public Key and the Next VRF Public Key, your Active Client Seed and Total Bets. A bet’s detail modal shows its VRF Public Key, VRF Proof and VRF Output.

When you rotate the pair, the next key becomes active, a new next key is created, the nonce restarts from zero (the next bet uses nonce 1) and your previous pair is listed with its public key. There is no secret seed to reveal, since every bet was already provable. The public key stays the same until you rotate.

Verify a bet

You need the VRF public key, client seed, nonce, VRF proof and VRF output of the bet. The verification recomputes alpha, checks the proof and compares the output the proof yields with the one you were given:

output = ECVRF_verify(publicKey, alpha, proof) // null when the proof is invalid valid = output !== null && output === vrfOutput

Then derive the bytes from the output and play the game event as usual.

Reference code

Run with npm install @noble/curves @noble/hashes. Save as ecvrf.mjs.

import { ed25519 } from "@noble/curves/ed25519.js"; import { sha512 } from "@noble/hashes/sha2.js"; import { concatBytes, bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { createHmac } from "node:crypto"; const Point = ed25519.Point; const SUITE = Uint8Array.of(0x04); const bytes = (...values) => Uint8Array.from(values); // Little-endian bytes to integer. const leToInt = (b) => b.reduceRight((n, byte) => (n << 8n) + BigInt(byte), 0n); /** alpha, the VRF input of a bet. */ export const vrfAlpha = (clientSeed, nonce) => new TextEncoder().encode(`${clientSeed}:${nonce}`); // Try-and-increment hash to curve (RFC 9381 section 5.4.1.1). function hashToCurve(publicKey, alpha) { for (let counter = 0; counter < 256; counter++) { const hash = sha512(concatBytes(SUITE, bytes(1), publicKey, alpha, bytes(counter), bytes(0))); const candidate = hash.slice(0, 32); candidate[31] &= 0x7f; try { const point = Point.fromBytes(candidate).multiply(8n); // clear the cofactor if (!point.equals(Point.ZERO)) return point; } catch { // not a valid point: try the next counter } } throw new Error("hash to curve failed"); } function challenge(H, Gamma, U, V) { const hash = sha512( concatBytes(SUITE, bytes(2), H.toBytes(), Gamma.toBytes(), U.toBytes(), V.toBytes(), bytes(0)), ); return leToInt(hash.slice(0, 16)); } /** * Checks a proof and returns the 64-byte VRF output as hex, or null if the * proof is invalid. The proof is 80 bytes: Gamma (32) + c (16) + s (32). */ export function vrfVerify(publicKeyHex, alpha, proofHex) { try { const publicKey = hexToBytes(publicKeyHex); const proof = hexToBytes(proofHex); if (proof.length !== 80) return null; const Gamma = Point.fromBytes(proof.slice(0, 32)); const c = leToInt(proof.slice(32, 48)); const s = leToInt(proof.slice(48, 80)); const Y = Point.fromBytes(publicKey); const H = hashToCurve(publicKey, alpha); const U = Point.BASE.multiply(s).subtract(Y.multiply(c)); // s·B − c·Y const V = H.multiply(s).subtract(Gamma.multiply(c)); // s·H − c·Gamma if (c !== challenge(H, Gamma, U, V)) return null; return bytesToHex(sha512(concatBytes(SUITE, bytes(3), Gamma.multiply(8n).toBytes(), bytes(0)))); } catch { return null; } } /** Bytes of the stream: the VRF output for rounds 0 and 1, then HMAC keyed by the output. */ export function* vrfByteStream({ vrfOutputHex, clientSeed, nonce, cursor = 0 }) { const output = hexToBytes(vrfOutputHex); let round = Math.floor(cursor / 32); let offset = cursor % 32; while (true) { const block = round < 2 ? output.slice(round * 32, round * 32 + 32) : createHmac("sha256", output).update(`${clientSeed}:${nonce}:${round}`).digest(); for (; offset < 32; offset++) yield block[offset]; offset = 0; round++; } }

The construction follows the structure of RFC 9381  (Ed25519, SHA-512, suite byte 0x04) with the try-and-increment hash-to-curve. Use the code above for byte-exact results: a library that implements a different RFC 9381 suite or hash-to-curve will not reproduce these outputs.

Worked example

This uses the same client seed and nonce as the other examples, and a test key (the Ed25519 test key from RFC 8032, which is public). It is not a real player’s key.

Private key9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
VRF public keyd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
Client seedmaktub-docs
Nonce1
alphamaktub-docs:1

The server produces:

proof f631aeafa3980772bae0572fdba816f0b06b7aa3918bddbb15ae1d9f0a519a8ecc2cb4bd04c74dc8deadd1209ee151d6b2fda4b925f211cb7db346aeccd1a99bf07608d65f7234c5c200e0181fe32f06 output 2ff2170e41a857944627c3e9fc76a917a5124579434d8b7631b8d8e2d894c86a2db40f99ba927c82d9abb6692892e29f6e9b16192236b862fa1118804d00c7f6

Verification:

import { vrfAlpha, vrfVerify, vrfByteStream } from "./ecvrf.mjs"; const publicKey = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; const proof = "f631aeafa3980772bae0572fdba816f0b06b7aa3918bddbb15ae1d9f0a519a8ecc2cb4bd04c74dc8deadd1209ee151d6b2fda4b925f211cb7db346aeccd1a99bf07608d65f7234c5c200e0181fe32f06"; const output = vrfVerify(publicKey, vrfAlpha("maktub-docs", 1), proof); // "2ff2170e…00c7f6" (null if the proof is invalid) const stream = vrfByteStream({ vrfOutputHex: output, clientSeed: "maktub-docs", nonce: 1 }); const first4 = [0, 1, 2, 3].map(() => stream.next().value); // [47, 242, 23, 14]

The first four bytes 47 242 23 14 are the first four bytes of the output, so the first float is

47 / 256 + 242 / 256² + 23 / 256³ + 14 / 256⁴ = 0.18728775111958385

and a Dice bet with this float rolls 0.18728775… × 10001 / 100 = 18.73.

Realtime games

In EC-VRF mode, Crash, Slide and Double use the engine’s VRF key in place of the server seed. See Crash, Slide & Double.

Last updated on