Skip to main content

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.

Verify a pack right now

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

StepDecidesDriven byCommitted on-chain as
1. The rollyour tierECVRF output (beta bytes 0–8)a VRF proof commit (cc-vrf)
2. The walkyour cardthe 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

  1. The input is bound to your signature. When you open a pack, the VRF input (alpha) is SHA-256 of 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 used SHA-256(memo + purchase-tx-signature) instead; either way the input is bound to something you produce by signing.)
  2. We evaluate the VRF. Our server runs ECVRF with its secret key over your alpha, producing an 80-byte proof and a 64-byte output beta.
  3. The roll comes from beta. We read the first 8 bytes of beta as 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].
  4. The roll maps to a tier using the machine's published rarity weights (see GET /api/machines for 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 walk rank = 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 P in that sequence; the cards considered "taken" are exactly those with a win event before P and 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:

  1. Register and freeze a key. The operator registers its VRF public key on-chain as a VrfAuthority, then calls freeze_authority — a one-way action. After freezing, the public key and ciphersuite are permanent and cannot be swapped.
  2. Commit each proof. For every pack, after producing the proof we call commit_proof_with_beta, which writes a Light Protocol compressed PDA storing SHA-256(proof), SHA-256(alpha), SHA-256(memo), the slot, and the 64-byte beta. 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 beta came from our frozen key over your alpha; and
  • the sel_anchor memo 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. alpha is 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 beta per (public key, alpha). Our public key is frozen on-chain and unrevoked. Any substitute proof fails verification against the frozen key, and the original SHA-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 change beta (can't — see above), the committed candidate set, or the timeline position — each of which changes the sel_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 beta from alpha requires the VRF secret key, which only we hold. You choose your alpha (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 beta you 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 from beta, and hard-checks the recomputed sel_anchor against 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_MEMO returns 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:

CheckWhat it proves
Roll verified (cc-vrf, not degraded)the roll is a genuine ECVRF roll from our frozen key
Merkle root of candidates matchesthe 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 cardreplaying the shuffle from beta reproduces your exact card
Awarded value within the tier bandyour 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