Skip to content

Verifying outcomes

The point of VRAND is that you never take anyone's word for a random value — not the prover's, not the RPC's, not ours. This page is the complete verification story.

What the SDK verifies for you, by default

Every @vrand.io/web result passes local verification before your code sees it:

  1. Alpha binding. The SDK re-derives the committed input alpha = SHA-512(DOM ‖ requester ‖ seed ‖ slot ‖ slothash ‖ key_epoch)[..32] from its own requester and seed, plus the slot/slothash/epoch stored on the request account. If the served record's alpha differs, the record is not this request's — rejected, regardless of how valid its proof is in isolation.
  2. Proof verification. The 80-byte ECVRF proof is checked against the prover's registered public key and that alpha, and the served beta is checked to be the one the proof commits to.

A failure is a thrown VerificationFailed — never a number.

The slothash is a bank hash

The slothash bound into alpha is the previous slot's bank hash — not a blockhash, and no standard RPC serves it. The request account persists it for exactly this reason. Serving it back is safe: with your requester and seed known locally, no substituted slothash can make a foreign request's alpha match yours (preimage resistance).

Verifying someone else's outcome

Anyone can audit any fulfilled request from public data:

ts
import { decodeRequest, verifyBoundBeta } from "@vrand.io/client";

const info = await connection.getAccountInfo(requestAddress);
const req = decodeRequest(info.data);
verifyBoundBeta(req.vrfPubkey, req.alpha, req.proof, req.beta, {
  requester: claimedRequester.toBytes(),
  seed: claimedSeed,
  requestSlot: req.requestSlot,
  slothash: req.slothash,
  keyEpoch: req.keyEpoch,
}); // throws on any mismatch

Offline, without any chain access, the signer binary does the same:

bash
vrand-signer verify --pubkey <hex> --alpha <hex> --proof <hex>

Shaping is part of fairness

A perfect VRF output shaped with beta % n is biased for any n that is not a power of two — a real fairness defect and an automatic certification finding. The sanctioned path is the scale module (Rust: vrand_vrf_core::scale; TypeScript: exported from @vrand.io/client):

  • uniform_below(beta, n) — rejection sampling, zero bias for any bound
  • shuffle_indices(beta, n) — Fisher–Yates over the same stream
  • weighted(beta, weights) — integer weights, zero-weight entries never win
  • unit_float(beta) — top 53 bits over 2^53, Math.random() semantics
  • Stream — the stateful form: one output, a mixed sequence of draws

The stream expands beta deterministically (beta itself first, then SHA-512(dom ‖ beta ‖ i) blocks), and the Rust and TypeScript implementations are byte-identical, pinned by cross-language golden tests that both suites run.

Draw receipts

For giveaways, allowlists, and similar selections, deriveWinners(beta, entrants, count) is the published derivation: unweighted lists take the first count names of the stream's permutation; weighted lists draw sequentially without replacement. Entrant order matters, which is why a receipt embeds the full ordered list:

json
{
  "kind": "vrand-draw-receipt",
  "requester": "…", "seed": "…hex…", "request": "…address…",
  "entrants": ["ann", "bo", "cy"], "count": 1, "winners": ["bo"]
}

Verification is mechanical: fetch the request, verifyBoundBeta with the receipt's requester and seed, re-run deriveWinners, compare. The app does this in one paste.

The fallback path (you will probably never see it)

With probability ≈ 4 × 10⁻¹⁵ per request, a (key, alpha) pair is mathematically unfulfillable by the suite. The protocol resolves these with a deterministic fallback output after an on-chain exhaustive scan proves the premise. Such requests carry a sentinel (taiCtr == 0xffff, zeroed proof) and verify via verifyBoundFallbackBeta instead — the SDK handles this automatically.

Apache-2.0. Live on Solana devnet.