Provable Fairness (VRF)
Every pack is fair in two independent, verifiable steps: a VRF roll decides your tier (Common / Uncommon / Rare / Epic), then a second provable step decides which card you win inside that tier. Both steps come from the same cryptographic VRF output, and everything needed to re-check them is published on Solana. We can't predict, retry, or steer either one — and neither can you. This page explains how, and how to verify any pack yourself.
Open /verify-selection/YOUR_MEMO in your browser, or call
GET /api/vrf/verify?memo=YOUR_MEMO. The browser page recomputes everything from scratch
(client-side) and checks it against the on-chain record. See Verify your pack.
At a glance
| Step | Decides | Driven by | Committed on-chain as |
|---|---|---|---|
| 1. The roll | your tier | ECVRF output (beta bytes 0–8) | a VRF proof commit (cc-vrf) |
| 2. The walk | your card | the same ECVRF output (beta bytes 8–24) | the sel_anchor in the award transaction's memo |
A Verifiable Random Function (VRF) is a keyed function: for an input alpha, the key
holder produces a random-looking output beta and a proof that beta is the one and
only correct output for that key and that input. Anyone with the public key can verify the
proof. The holder cannot bias the result, and it's reproducible by every verifier. We use
the RFC 9381 ECVRF-EDWARDS25519-SHA512-TAI suite — see the spec links.
Step 1 — The roll picks your tier
- The input is bound to your signature. When you open a pack, the VRF input
(
alpha) isSHA-256of your wallet's signature on the pack, under a fixed domain tag. We cannot know or influence it until after you've signed — so we can't precompute or "shop for" a favorable roll. (Very old packs usedSHA-256(memo + purchase-tx-signature)instead; either way the input is bound to something you produce by signing.) - We evaluate the VRF. Our server runs ECVRF with its secret key over your
alpha, producing an 80-byteproofand a 64-byte outputbeta. - The roll comes from
beta. We read the first 8 bytes ofbetaas a big-endian unsigned integer and reduce it:roll = (beta[0..8] as u64) mod 100,000,000 + 1, a uniform integer in [1, 100,000,000]. - The roll maps to a tier using the machine's published rarity weights (see
GET /api/machinesfor the live odds per machine).
Because ECVRF (RFC 9381) guarantees exactly one valid proof and one beta per
(public key, alpha), there is no second roll to reach for. The proof either verifies
against our public key or it doesn't.
Step 2 — The leaf pool and the Feistel walk pick your card
Once the tier is known, the same beta selects the specific card — over a candidate
set that was frozen and committed before the card was picked.
The leaf pool (the committed candidate set)
The "leaf pool" is the machine's live prize inventory, snapshotted into an immutable pool version and committed as a Merkle tree:
- Every eligible card becomes a leaf:
leaf = SHA-256( 0x00 || nft_mint(32 bytes) || insured_value_in_cents(u64 BE) ). - Leaves are grouped by tier (epic / rare / uncommon / common) and hashed into a per-tier Merkle root.
- The four tier roots, their counts, and the version number are folded into one
version_root— a single 32-byte commitment to the entire candidate set of that pool version.
The pool is rebuilt periodically on a randomized cadence as inventory changes (cards added,
won, or returned). Each rebuild produces a new pool version with a new version_root. When
you open a pack, it is pinned to whatever pool version is current at that moment — so
the set of cards you "could have won" is fixed and committed up front, not chosen after the
roll.
The Feistel walk
A Feistel network is the standard way to turn a small secret seed into a
pseudo-random permutation — a keyed shuffle that maps the positions
0, 1, ..., N-1 onto themselves with no repeats and no gaps (a bijection). Think of it as
shuffling the tier's candidate cards with a key, then dealing from the top.
- The shuffle's key is a different window of the same
beta: bytes 8–24 (the roll used bytes 0–8). One VRF output drives both steps, so the card pick is just as unpredictable and just as verifiable as the roll. permIndex(rank)returns the card index at a given position in the shuffle. We walkrank = 0, 1, 2, ...and award the first card that is still available — skipping any card already won by an earlier pack and not yet returned.- "Already won" is determined by a global, monotonic ownership sequence. Your award
sits at some point
Pin that sequence; the cards considered "taken" are exactly those with a win event beforePand no later return (a card that was bought back becomes available again). The verifier replays these same events.
Why a walk rather than index = beta mod N? Cards are being won concurrently by other
buyers, so a single index could land on a card that's already gone. The Feistel permutation
gives a complete, deterministic fallback order for the whole tier, so "skip taken, take
the next" is itself fully reproducible and unbiased — there is no hidden tie-breaker.
sel_anchor — the on-chain commitment to your card
The exact card you got is committed with a 32-byte selection anchor:
sel_anchor = SHA-256( 0x04 || version_root || ownership_seq(u64 BE) || awarded_nft_mint(32 bytes) )
This binds three things at once: the entire candidate set (version_root), the exact
point in the global ownership timeline (ownership_seq), and the specific card. The
sel_anchor is written into the memo of the award transaction (the on-chain NFT
transfer, or the buyback transaction for turbo packs) as a fixed-width :r<64-hex> suffix.
Because that memo is immutable once the transaction confirms, the card you received is
locked to the committed pool and timeline forever.
How proofs are committed on-chain
The VRF proofs are committed through cc-vrf, a standalone, permissionless on-chain VRF
system for Solana (program ccvrfu3fSpbnPLiUqdWAt85Zn9nq96ekwGTbHqGtdgQ, live on
mainnet + devnet). The commit process:
- Register and freeze a key. The operator registers its VRF public key on-chain as a
VrfAuthority, then callsfreeze_authority— a one-way action. After freezing, the public key and ciphersuite are permanent and cannot be swapped. - Commit each proof. For every pack, after producing the proof we call
commit_proof_with_beta, which writes a Light Protocol compressed PDA storingSHA-256(proof),SHA-256(alpha),SHA-256(memo), the slot, and the 64-bytebeta. The PDA address is derived from(authority, memo_hash), so the chain enforces one commit per memo — a proof can't be silently replaced later without it being detectable.
So each pack carries two independent on-chain commitments:
- the cc-vrf commit — proves the roll's
betacame from our frozen key over youralpha; and - the
sel_anchormemo on the award transaction — pins which card you got, over which committed candidate set.
Verification (/api/vrf/verify) re-runs the ECVRF math off-chain and fetches the
on-chain commit + authority to confirm the authority is frozen and unrevoked, the committed
hashes match, and the recomputed sel_anchor equals the one in the award memo.
Why it can't be exploited
By us (the operator)
- We can't precompute or steer the roll.
alphais bound to your signature, which we don't have until you sign. There's nothing to grind against ahead of time. - We can't retry for a better roll. RFC 9381 ECVRF yields exactly one valid proof and
one
betaper(public key, alpha). Our public key is frozen on-chain and unrevoked. Any substitute proof fails verification against the frozen key, and the originalSHA-256(proof)is already committed on-chain — so swapping it after the fact is detectable. - We can't hand-pick your card. Given
beta, the Feistel walk is fully determined over the candidate set that was frozen and committed (version_root) before the pick. To award a different card we'd have to changebeta(can't — see above), the committed candidate set, or the timeline position — each of which changes thesel_anchor, which is pinned to the immutable award-transaction memo. Any mismatch shows up as NOT VERIFIED. - We can't quietly shrink the pool to withhold a card. All four tier roots and counts
are folded into
version_root; removing or altering any card changes the root.
By you (the buyer)
- You can't predict the output. Computing
betafromalpharequires the VRF secret key, which only we hold. You choose youralpha(indirectly, via your signature), but the VRF's unpredictability means you can't find a signature that yields a favorable roll without breaking the VRF. - You can't choose your card. It's a deterministic function of a
betayou can't predict. - You can't forge or replay a different outcome. You can verify a proof after the fact, but only the holder of the secret key can produce a valid one.
The "memo selection" angle
The only theoretical lever in any commit-style VRF is choosing which committed input to
honor. We remove it by binding alpha to your own wallet signature and issuing the memo
before you sign: we can't choose your signature, and you can't compute the VRF to know which
signature would be favorable. Neither side can shop for an outcome.
Verify your pack yourself
- Card selection (full replay) — open
/verify-selection/YOUR_MEMO. Entirely in your browser, it re-fetches the pinned candidate set, recomputes the Merkle and version roots, replays the Feistel walk frombeta, and hard-checks the recomputedsel_anchoragainst the on-chain memo. The candidate set is retained for a short verification window (~1 hour) after the pool is rebuilt; after that the roll and on-chain anchor remain independently verifiable even though the full card list can no longer be reproduced. - Roll (proof + roll match) —
GET /api/vrf/verify?memo=YOUR_MEMOreturns the proof, public key,alpha/beta, the on-chain commit status, the awarded transaction signature, and whether the stored roll matches the recomputed roll.
The browser verifier shows these checks, all of which must pass for a green VERIFIED:
| Check | What it proves |
|---|---|
| Roll verified (cc-vrf, not degraded) | the roll is a genuine ECVRF roll from our frozen key |
| Merkle root of candidates matches | the published candidate set is the one that was committed |
| On-chain memo anchor matches (hard) | your card is the one pinned in the immutable award memo |
| Feistel walk lands on your card | replaying the shuffle from beta reproduces your exact card |
| Awarded value within the tier band | your card's insured value is inside the tier's published band |
Precise construction (for re-implementers)
All hashes are SHA-256. || is byte concatenation. Mint addresses are base58-decoded to
their raw 32 bytes. These constants are a frozen contract — they never change, or
verification of past packs would break. The reference implementation is isomorphic (the
same code runs on our server and in your browser).
roll = (beta[0..8] as u64 BE) mod 100_000_000 + 1
selection seed= beta[8..24] # 16 bytes; beta[24..] reserved
leaf(nft,val) = SHA-256( 0x00 || nft(32B) || cents(val) as u64 BE )
node(L,R) = SHA-256( 0x01 || L || R ) # odd level duplicates the last node
tier_root = Merkle root of the tier's leaves # empty tier => 32 zero bytes
version_root = SHA-256( 0x02 || version(u64 BE)
|| root_epic || root_rare || root_uncommon || root_common
|| count_epic || count_rare || count_uncommon || count_common ) # counts are u32 BE
sel_anchor = SHA-256( 0x04 || version_root || ownership_seq(u64 BE) || awarded_nft(32B) )
# Feistel permutation over [0, len): 4-round alternating additive Feistel, cycle-walked
# down to the exact domain. Round function:
F(seed16, round, half) = SHA-256( "ccgacha-sel-v1\0" || seed16 || round(u8) || half(u32 BE) ),
folded to 48 bits, then reduced mod the half's size.
cents(val) mirrors round(insured_value * 100). The on-chain memo carries sel_anchor
as a trailing :r<64-hex> segment (fixed width, so legacy memos still parse).
Spec & source
- Standalone VRF verifier & internals — vrf.collectorcrypt.com
- cc-vrf (program + SDK + ECVRF library), MIT — github.com/collectorcrypt/cc-vrf
- On-chain program —
ccvrfu3fSpbnPLiUqdWAt85Zn9nq96ekwGTbHqGtdgQ - VRF standard — RFC 9381: Verifiable Random Functions (VRFs) (ciphersuite ECVRF-EDWARDS25519-SHA512-TAI)
- Packages —
@collectorcrypt/ecvrf(RFC 9381 ECVRF) and@collectorcrypt/vrf-client(Solana SDK:verifyAuthorityCommitEndToEnd,pickCanonicalCommit)