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.

Which commit mode we use

The cc-vrf program offers three commit instructions. We use the richest one:

InstructionWhat lands on-chainUsed
commit_proof_eventan event log only — no accountno
commit_proofmemo hash + proof hash + alpha hash (136 bytes)no
commit_proof_with_betathose three hashes plus the full 64-byte beta (200 bytes)yes

Storing beta in the clear is what lets another Solana program read the random value directly, and lets a verifier compare the beta it derives from the proof against the one that was committed. The verify page's registry-beta mode is the one that checks it.

What the commit record contains

The compressed PDA is exactly 200 bytes, in this order:

OffsetSizeField
032authority address
3232SHA-256(memo)
6432SHA-256(proof)
9632SHA-256(alpha)
12864beta (stored as two 32-byte halves, beta_lo + beta_hi)
1928committed_slot (u64 LE)

memo, alpha and proof are stored as fingerprints, not as raw values. A fingerprint can't be reversed, but it can't be lied about either: once it's on-chain, the only values that will ever match it are the originals. We publish the originals through /api/vrf/verify, and anyone can re-hash them and compare.

beta is the exception — it goes on-chain raw. That's deliberate (see above).

How the on-chain hash is built

Two different hashings get confused for each other, so to be explicit:

  • beta = proof_to_hash(proof) makes the random number. Deterministic — the same proof always yields the same beta.
  • The SHA-256 fingerprints above have nothing to do with randomness. They only lock the values in place.

The record itself is anchored to Solana in four rungs:

  1. Fingerprints. We SHA-256 the memo, the alpha and the proof before sending anything.
  2. Data hash. Light Protocol squeezes the 200-byte record into a single 32-byte data hash (Poseidon, not SHA-256).
  3. Leaf hash. That data hash is combined with the record's identity — program ID, state tree, leaf index, compressed address, account discriminator — into one leaf hash.
  4. Merkle root. The leaf is inserted into a Light state Merkle tree whose root lives in a real on-chain account.

So one 32-byte root covers the whole record. Change a single byte anywhere below it — swap the proof, nudge the alpha, edit the roll — and the root no longer matches what the chain holds.


Worked example — from a memo to the on-chain transaction

Real mainnet pack. Memo cc-77161af0-8001-425b-9213-e5e1d24083d6 (pokemon_50, 2026-08-07).

Steps 1–4 are pure math — no network, no API, nothing from us. Steps 5–6 read the chain.

1. Start with the memo. It's printed on your pack.

cc-77161af0-8001-425b-9213-e5e1d24083d6

2. Hash it.

SHA-256(memo) = 0x46f31a79c1ae2b87999094278a29f0c6f50843998112fb10fb2e97511629c841

3. Derive the authority address from "vrf_authority" || owner || label, where the owner is the gacha wallet GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3 and the label is gacha:

13C5cbemzwSg5wNerJDucU5uUJ1kHQENDqoTa3VSAHau

4. Derive the commit address from "vrf_proof" || authority || memo_hash:

1fNrGDCyJrKBEheLcvhTKJXkLrAmWsM7FNXP7cR1hNF

Nothing secret went into that. Anyone holding the memo lands on that exact address — which is also why we can't hide a commit or write a second one for the same pack. The address is already taken.

5. Read the record. Leaf 744951 in state tree bmt1LryLZUMmF7ZtqESaw7wifBXLfXHQYoE4GAmrahU. Its 200 bytes decode to:

authority   008fb0761e0d68e818644fe8f6fee08de44858c5e6e3c891364c441e5847c666
memo hash 46f31a79c1ae2b87999094278a29f0c6f50843998112fb10fb2e97511629c841
proof hash 23c025b668c5eee717756a4e519435cf553331fd83173c116a331fb374d6e62c
alpha hash a35e5296e8596a17f723ba7ee60fb91274d2865590cdc10e3507dbcc0d32e3b0
beta e15d44f4181c1a9016a0589be046ef3c3fbc779b5767ca5ebfb65abe2c4014ba
6568514ccb234c808fee0912658e1ff1479ca5c6915e3433a673ac5a9a1ac351
slot f734191a00000000 → 437,859,575

6. Ask which transaction created that leaf.

2PnAaXGBtutqXah7rmZA7Fj6Mu4GMGRwUwKbeDtmhuXaaQYb7djwniJM73PD5pSxAqDNJsBrXD8PEd4oU63ujpWD
slot 437,859,575 — 2026-08-07 20:51:27 UTC

That signature is a normal Solana transaction. Open it in any explorer and the logs read:

Program ccvrfu3fSpbnPLiUqdWAt85Zn9nq96ekwGTbHqGtdgQ invoke [1]
Program log: Instruction: CommitProofWithBeta
caution

Paste the transaction signature into an explorer, not the commit address. The commit address is a Light Protocol compressed address — it isn't a regular Solana account, so standard explorers won't find it. Use the Lookup page at vrf.collectorcrypt.com for compressed addresses.

Checking the values against that record

The published proof and alpha for this pack (from /api/vrf/verify?memo=…):

alpha 0x800fb99a079d0823110497f6d41d56672c55a58e556426303d3541884f7ed1f1
proof 0x0dfc748650f44a04f9726f3155f0202ef69a1bd0eda7fd9e684cc5ab707ed2c7
5de8c77faa88f920203d26739ae2c193bf15b93474e055886b67aad727e19fca
1fb83aaaa50939d54eef6d02ae83df06

Five checks, all reproducible:

CheckResult
SHA-256("ccgacha-vrf-v1\0" ‖ your signature) == alphamatches — the input came from the buyer's own signature 2hdYcPFQ…hvE2
verifyVRF(pk, alpha, proof) under the frozen key 0x115c9962…cb83valid
SHA-256(memo) / SHA-256(alpha) / SHA-256(proof) == the three on-chain fingerprintsall three match
proof_to_hash(proof) == the on-chain betamatches
beta[0..8] → the roll16,239,211,646,535,080,592 mod 100,000,000 + 1 = 35,080,593 — the stored roll

And the card, from the award transaction io6XatGJb1TogSGEBpQQC7QP8QGLH3QTfTqqeLzy9x1PZBTadyc8pCiRyHTEiAxaYCY7zRtVsJ5HUnLaVMtvSvB, whose on-chain memo carries the sel_anchor:

cc-77161af0-8001-425b-9213-e5e1d24083d6:send:r181ed2e79df8903b3ad811fc26ff590122ecd4442a82ccce9d690ac7e1a5968a

Recompute SHA-256(0x04 ‖ version_root ‖ ownership_seq ‖ awarded_nft) and you get that same 181ed2e7…968a. Two independent on-chain commitments, one per step.


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