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 | |
|---|---|---|
| Commitment | Hash of a secret server seed | A public key (Ed25519) |
| What the server reveals | The old server seed, at rotation | Nothing secret: a proof with every bet |
| When you can verify a bet | After you rotate the seed pair | Right away |
| Secret key | Revealed when the pair is retired | Never revealed |
How it works
-
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.
-
Input. For each bet the VRF input, called
alpha, is built from your seed and the nonce:alpha = UTF-8(`${clientSeed}:${nonce}`) -
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. - a VRF proof of 80 bytes:
-
Bytes. The 64 output bytes are the random bytes:
Round Bytes 0Output bytes 0 to 31 1Output bytes 32 to 63 2and aboveHMAC-SHA256 keyed by the 64 output bytes, over the message clientSeed:nonce:roundThe 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 === vrfOutputThen 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 key | 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 |
| VRF public key | d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a |
| Client seed | maktub-docs |
| Nonce | 1 |
alpha | maktub-docs:1 |
The server produces:
proof f631aeafa3980772bae0572fdba816f0b06b7aa3918bddbb15ae1d9f0a519a8ecc2cb4bd04c74dc8deadd1209ee151d6b2fda4b925f211cb7db346aeccd1a99bf07608d65f7234c5c200e0181fe32f06
output 2ff2170e41a857944627c3e9fc76a917a5124579434d8b7631b8d8e2d894c86a2db40f99ba927c82d9abb6692892e29f6e9b16192236b862fa1118804d00c7f6Verification:
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.18728775111958385and 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.