Provably Fair
Randomness provided by
This page is for developers and users who want to understand, inspect, and independently verify the fairness model used by GalaxyGrails.io.
It complements the high-level overview on the Fairness & Transparency page with concrete API details and working verification code.
Overviewโ
GalaxyGrails.io uses a combination of:
- Chainlink VRF (Verifiable Random Function) for on-chain randomness
- A pre-committed deck snapshot and SHA-256 deck hash, submitted on-chain
- A per-event secret salt whose hash is bound into the on-chain commitment before the seed exists, and which is published when the event ends (algorithm version 2)
- A deterministic fairness algorithm that derives each pull from the seed and, under version 2, the salt
The key idea:
Before a single pack is sold, the deck is fixed and a commitment to it is written on-chain, and a Chainlink VRF seed is committed for the event. From that moment the outcome of every pull is fully determined. No one โ including GalaxyGrails โ can change a result afterwards without the published data failing to verify.
Why there are two algorithm versionsโ
Version 2 strengthens how each pull is derived. A Chainlink VRF seed is public on-chain once fulfilled, so version 2 mixes in a per-event secret that is published when the event ends. Outcomes stay fixed in advance and fully verifiable afterwards.
Selection uses an effective seed derived from both the VRF output and that secret salt:
effective_seed = SHA256( bytes.fromhex(random_seed) || bytes.fromhex(seed_salt) )
While the event runs, the salt is withheld, so the effective seed is unknown even though the chain seed and the deck order are both public. Which card comes next cannot be computed. When the event ends the salt is published and every pull replays exactly as before.
The salt cannot be chosen to suit a result, because its commitment SHA256(salt) is bound into the value anchored on-chain in the same transaction that requests randomness, which happens before the oracle produces the seed. Anchoring is write-once, so that commitment can never be replaced.
Existing version 1 events are unchanged and still replay under the version 1 rule. The version an event used is published as fairness_algo_version, and it is fixed for the life of that event.
This guide walks through the public fairness endpoint and shows how to:
- Fetch fairness data for a completed event
- Recompute the deck hash from the snapshot
- Reproduce the exact card selected for every pull
- Confirm your reconstruction matches the published pull timeline
You can browse completed events and their fairness reports in the UI at https://galaxygrails.io/events.
For more details on how Chainlink VRF itself works under the hood, see the official Chainlink VRF documentation.
Fairness Algorithmโ
Each GalaxyGrails pack event follows this pattern:
- Fix the deck โ Before sales start, the full list of cards for the event is locked into a snapshot and hashed (the
deck_hash). - Generate and commit the salt โ A 32-byte secret salt is generated for the event, and its commitment
SHA256(salt)is published asseed_commitment. The salt itself stays secret until the event ends. (Version 2 only.) - Commit on-chain โ A single value is submitted to the VRF consumer contract together with a derived event ID, via
createEvent(bytes32 eventId, bytes32 deckHash). That value is thedeck_commitmentfor version 2, or the baredeck_hashfor version 1, and the same call requests the randomness. The commitment is public and immutable from that point on. - Fix the randomness โ Chainlink VRF returns a single verifiable random seed (
random_seed) for that event. Packs cannot be sold until the seed is committed. - Derive each pull โ For every pull, a SHA-256 digest over the seed (the effective seed under version 2), the pull's position in the event, and the buyer's user ID selects one card from those still unpulled.
- Reveal over time โ As packs are opened, each selected card is revealed live.
- Publish the salt โ When the event ends,
seed_saltis published alongside the seed, the deck snapshot and the pulls, and the whole event becomes replayable. (Version 2 only.)
For a version 2 event, the deckHash argument of createEvent is:
deck_commitment = SHA256( bytes.fromhex(deck_hash) || bytes.fromhex(seed_commitment) )
If you read the contract and compare its stored value against the API's deck_hash, they will not match, and that is expected. Compare against deck_commitment instead. The API publishes it so you do not have to recompute it, but you should recompute it anyway, since checking it yourself is the point.
For a version 1 event, the anchored value is the bare deck_hash, and deck_commitment is absent.
Because the deck snapshot, deck hash, salt, VRF seed, and pulls are all exposed via the fairness API once the event ends, anyone can rebuild the initial deck state, re-derive every selection, and confirm the result matches the published pulls timeline.
That is what makes the system provably fair rather than just procedurally random.
The selection ruleโ
For each pull, the server computes an index into the cards still unpulled. The two versions differ only in the seed bytes that go in, and in the trailing domain separator.
Version 2 (current):
effective_seed = SHA256( bytes.fromhex(random_seed) || bytes.fromhex(seed_salt) )
R = int.from_bytes(
SHA256( effective_seed
|| uint64_be(sequence_index)
|| utf8(str(user_id))
|| b"pack_open_v2" ),
byteorder="big")
idx = R mod len(remaining_cards)
Version 1 (earlier events, unchanged):
R = int.from_bytes(
SHA256( bytes.fromhex(random_seed)
|| uint64_be(sequence_index)
|| utf8(str(user_id))
|| b"pack_open_v1" ),
byteorder="big")
idx = R mod len(remaining_cards)
Note that version 2 substitutes the effective seed for the raw seed and changes the separator to pack_open_v2. Everything else, including the deck ordering, the sequence numbering and the modulo, is identical. Do not mix the two: a version 2 event replayed with the version 1 rule reproduces nothing.
remaining_cards is the list of cards from deck_snapshot that have not yet been pulled, in the order they appear in deck_snapshot. The card at idx is pulled and removed from the list; the next pull operates on the shortened list.
deck_snapshot order is the canonical, committed order โ the server sorts its own candidate pool into that order before indexing into it, so your reconstruction and the server's agree by definition.
Five details matter if you are reimplementing this:
- There is no PRNG and no RNG state. Each pull is an independent one-shot hash. Do not seed a
random.Random(or any other generator) and walk a stream of values โ that will not reproduce anything. sequence_indexis encoded as 8 bytes, big-endian, unsigned. It is the pull's 1-based position in the event, and it is published on every pull.- The domain separator is a literal, appended last:
b"pack_open_v1"for version 1 andb"pack_open_v2"for version 2. Readfairness_algo_versionto pick which one applies, but write the literal itself rather than building the string from that field, since the two are independent by design. - The salt is hex, and it is decoded before hashing.
seed_saltis a 64-character hex string likerandom_seed. Concatenate the two as raw bytes, not as text: hashing the hex characters gives a different and wrong answer. - The buyer's
user_idis part of the input. Outcomes are therefore not a pure function of(seed, sequence_index)โ the same slot pulled by a different account yields a different card. This is intentional, and thepulls[]array publishesuser_idso verification remains fully possible.
The algorithm is unweighted: at each step every still-unpulled card in the deck is equally likely. Card price, tier, and rarity play no part in selection. (R mod N introduces a theoretical modulo bias, but R is 256 bits against a deck of a few hundred cards, so the bias is on the order of 2โปยฒโดโฐ โ far below any measurable effect.)
Which events are replayableโ
Card selection is strict: if a pull could not be reproduced from the published seed, the platform refuses to make that pull at all rather than falling back to some other source of randomness. Packs cannot even be sold on an event whose seed is missing or malformed. Replay is guaranteed by construction.
Use the public event listing at https://galaxygrails.io/events. It contains the events that replay from their published fairness data, so if you are enumerating events to verify, everything you find there will reproduce.
Fairness payloads also remain fetchable by ID for every event ever run, including early ones that predate the current fairness system. If you have reached an event by ID rather than through the listing, check before attempting a replay that:
fairness_algo_versionis"v1"or"v2",random_seedis present,- and, when the version is
"v2",seed_saltis present too.
A version 2 event that has not ended yet publishes neither its seed nor its salt, so there is nothing to replay until it closes. That is the intended behavior, not a gap.
If an event you expected to replay does not, contact [email protected] with the event ID and we will look into it with you.
Public Fairness Endpointโ
URLโ
GET https://api.galaxygrails.io/v1/pack-events/{PACK_EVENT_ID}/fairness
- Authentication: Not required (public)
- Path params:
PACK_EVENT_IDโ UUID of the pack event (e.g.11655b60-f6cc-40a8-b8a8-0282c4e5d9f1)
The fairness payload is returned as the top-level JSON object โ there is no {"data": ...} or {"success": true} envelope.
Behavior: Active vs Completed Eventsโ
The endpoint behaves differently depending on whether the event is still live:
-
While the event is active (
is_active = true):- The response includes VRF metadata, the deck hash, and both commitments (
seed_commitmentanddeck_commitment), but hides:- The actual
random_seed - The
seed_salt - The full
deck_snapshot - The full
pullsarray (returned as[])
- The actual
- This prevents leaking information about future pulls while still letting users verify the on-chain commitments.
- The response includes VRF metadata, the deck hash, and both commitments (
-
After the event ends (
is_active = false):- The response includes the full fairness payload:
random_seedrandom_seed_sourceseed_saltdeck_snapshotpulls(ordered timeline)
- The response includes the full fairness payload:
The rest of the fields (event name, price, deck hash, commitments, VRF metadata) are always present.
The commitments are deliberately published while the event is still running. They are what let you check, before you spend anything, that the deck and the salt were both fixed in advance and anchored on-chain. The values they commit to arrive only at the end, which is what keeps the outcome unpredictable in the meantime.
Example Response (Completed Event)โ
{
"pack_event_id": "11655b60-f6cc-40a8-b8a8-0282c4e5d9f1",
"pack_price": "200.00",
"stop_ev_buffer": 0.96,
"random_seed": "6a5f...c9d3", // VRF seed (hex, no 0x prefix)
"random_seed_source": "vrf",
"seed_salt": "5a4e...b71c", // Secret salt, published at event end (v2)
"seed_commitment": "4184...57b1", // SHA-256 of the salt, public from creation (v2)
"deck_hash": "f712...3b8e", // SHA-256 hash of deck_snapshot
"deck_commitment": "3d63...fbd7", // SHA-256(deck_hash || seed_commitment); the value anchored on-chain
"deck_snapshot": [
{
"pack_card_id": "...",
"card_inventory_id": "...",
"initial_price": "220.00"
}
// ... one entry per card in the event deck
],
"fairness_algo_version": "v2",
"pulls": [
{
"pull_id": "...",
"sequence_index": 1,
"user_id": "...",
"username": "collector123",
"pack_card_id": "...",
"card_token_id": "...",
"card": {
"id": "...",
"title": "2021 POKEMON ...",
"front_image": "https://...",
"back_image": "https://...",
"market_price": "250.00"
},
"pulled_at": "2025-11-24T03:12:45.123456+00:00"
}
// ... one entry per pull, ordered by sequence_index
],
"is_active": false,
"vrf_event_id": "0x...", // sha256 of the pack event UUID
"vrf_request_id": "0x...", // Transaction hash of the createEvent call
"vrf_consumer_address": "0x...", // VRF consumer contract address
"vrf_network": "polygon-mainnet" // Network label
}
Note: Field names and shapes are stable, but example values above are illustrative.
Key Fields Explainedโ
stop_ev_buffer: The auto-stop threshold, expressed as a ratio of the event's starting expected value โ not of the pack price. The event ends when the remaining EV drops belowstarting_EV ร stop_ev_buffer. EV on both sides is the meaninitial_priceacross the relevant cards. Sellout Break and Head-to-Head events skip this check and run until every pack is opened.fairness_algo_version: Version identifier for the selection algorithm, eitherv1orv2. It is fixed when the event is created and never changes afterwards, so an event that began under one version stays on it.random_seed: The Chainlink VRF output, as a zero-padded 64-character hex string with no0xprefix. A seed is validated as exactly 32 bytes before it is stored, and an event whose seed fails that check never opens for sale.seed_salt: The event's 32-byte secret, as a 64-character hex string. Version 2 only. It isnullfor the whole time the event is live and is published when the event ends. Hash it and compare againstseed_commitmentto prove it is the salt that was committed before the seed existed.seed_commitment:SHA256(salt)as hex. Version 2 only. Public from the moment the event is created, which is what makes the later reveal meaningful.deck_commitment:SHA256(deck_hash || seed_commitment)as hex, computed over the raw bytes of both. Version 2 only. This is the value written on-chain as thedeckHashargument ofcreateEvent, so it is the one to compare against the contract, notdeck_hash.random_seed_source:vrffor events seeded by Chainlink VRF.vrf_event_id:"0x" + sha256(pack_event_id), wherepack_event_idis the UUID in its standard lowercase hyphenated string form. You can recompute this yourself โ see below.vrf_request_id: The transaction hash of thecreateEventcall that requested randomness. This is not the ChainlinkrequestId.vrf_consumer_addressandvrf_network: The consumer contract and chain that this specific event was anchored on, recorded at the time it was submitted. The consumer has been redeployed before, so these are historical facts about the event rather than a description of today's configuration, and an older event correctly reports the older contract. Always look an event up at the address its own payload gives you.
Verifying the Deck Commitmentโ
The deck_hash is a SHA-256 hash of the serialized deck_snapshot. You can recompute it as follows:
import hashlib
import json
# Given a fairness API response in `data` (e.g. from resp.json())
deck_snapshot = data["deck_snapshot"]
serialized = json.dumps(deck_snapshot, sort_keys=True, separators=(",", ":"))
local_deck_hash = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
assert local_deck_hash == data["deck_hash"], "Deck hash mismatch!"
If this assertion passes, you know the deck used for the event is exactly the one committed via deck_hash โ and, because that hash was written on-chain when the event was created (directly for version 1, or bound into deck_commitment for version 2), exactly the one committed publicly before any pack was sold.
Do not reorder the array.
sort_keys=Truesorts the keys within each object; it does not sort the list itself. The order ofdeck_snapshotis significant twice over: it is the order that was hashed, and it is the order the selection index refers to. Serialize it exactly as the API returned it.
Recomputing the on-chain event IDโ
import hashlib
event_id = "0x" + hashlib.sha256(data["pack_event_id"].encode("utf-8")).hexdigest()
assert event_id == data["vrf_event_id"]
This lets you look the event up in the VRF consumer contract at vrf_consumer_address on vrf_network, and confirm that what is stored on-chain matches what you just verified locally.
Verifying the salt commitment and the on-chain valueโ
For a version 2 event, two more checks close the loop. Both are one line each:
import hashlib
# The revealed salt must match the commitment published before the seed existed.
assert (
hashlib.sha256(bytes.fromhex(data["seed_salt"])).hexdigest()
== data["seed_commitment"]
), "Salt does not match its commitment!"
# The anchored value binds the deck and the salt commitment together.
deck_commitment = hashlib.sha256(
bytes.fromhex(data["deck_hash"]) + bytes.fromhex(data["seed_commitment"])
).hexdigest()
assert deck_commitment == data["deck_commitment"], "Deck commitment mismatch!"
deck_commitment is the value to compare against the contract's stored deckHash for the event. The bare deck_hash will not match on-chain for a version 2 event, by design: what was anchored is the deck and the salt commitment bound together, so that neither can be swapped afterwards.
Taken together with the deck hash check above, these prove three things without trusting us: the deck was fixed before the event, the salt was fixed before the seed was drawn, and the pair of them was timestamped on a public chain in the same transaction that asked Chainlink for the randomness.
Reproducing the Pull Orderโ
This is the core of verification. Walk the pulls in order, deriving each selection index yourself and removing the chosen card from the pool:
import hashlib
def seed_bytes_and_tag(data):
"""Return the seed bytes and domain separator for this event's version."""
if data["fairness_algo_version"] == "v2":
effective_seed = hashlib.sha256(
bytes.fromhex(data["random_seed"]) + bytes.fromhex(data["seed_salt"])
).digest()
return effective_seed, b"pack_open_v2"
return bytes.fromhex(data["random_seed"]), b"pack_open_v1"
def select_index(seed, tag, sequence_index, user_id, remaining_count):
"""Reproduce the server's card selection for a single pull."""
h = hashlib.sha256()
h.update(seed)
h.update(int(sequence_index).to_bytes(8, "big", signed=False))
h.update(str(user_id).encode("utf-8"))
h.update(tag)
return int.from_bytes(h.digest(), "big") % remaining_count
seed, tag = seed_bytes_and_tag(data)
# `remaining` starts as the full committed deck, in snapshot order.
remaining = list(data["deck_snapshot"])
for pull in data["pulls"]:
idx = select_index(
seed,
tag,
pull["sequence_index"],
pull["user_id"],
len(remaining),
)
chosen = remaining.pop(idx)
assert chosen["pack_card_id"] == pull["pack_card_id"], (
f"Mismatch at sequence_index {pull['sequence_index']}"
)
If every assertion passes, you have independently confirmed that each card revealed during the event was the one uniquely determined by the pre-committed deck, the VRF seed and, for version 2, the pre-committed salt.
Two notes on interpreting the data:
deck_snapshotentries contain onlypack_card_id,card_inventory_id, andinitial_priceโ there is no card identity in the snapshot. To see which card each index corresponds to, join through thepulls[]array onpack_card_id.- The same procedure applies to every event format. Standard, Sellout Break, and Head-to-Head events all number their pulls 1..N and draw from the same shrinking pool.
Full End-to-End Verification Exampleโ
A complete script you can adapt. It:
- Fetches fairness data for a completed event.
- Recomputes the deck hash and asserts it matches.
- Checks the salt against its commitment, and rebuilds the value anchored on-chain (version 2).
- Recomputes the on-chain event ID.
- Reproduces every pull and compares against the published timeline.
It handles both algorithm versions, so you can point it at any event in the listing.
import hashlib
import json
import requests
EVENT_ID = "11655b60-f6cc-40a8-b8a8-0282c4e5d9f1" # Replace with your event ID
# 1) Fetch fairness JSON (returned as a top-level object, no envelope)
resp = requests.get(
f"https://api.galaxygrails.io/v1/pack-events/{EVENT_ID}/fairness"
)
resp.raise_for_status()
data = resp.json()
if data["is_active"]:
raise SystemExit("Event is still active โ seed and pulls are not published yet.")
version = data.get("fairness_algo_version")
if version not in ("v1", "v2") or not data.get("random_seed"):
raise SystemExit("This event was not seeded for deterministic replay.")
if version == "v2" and not data.get("seed_salt"):
raise SystemExit("Version 2 event has not published its salt yet.")
# 2) Verify the deck commitment
deck_snapshot = data["deck_snapshot"]
serialized = json.dumps(deck_snapshot, sort_keys=True, separators=(",", ":"))
local_deck_hash = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
assert local_deck_hash == data["deck_hash"], "Deck hash mismatch!"
print("Deck hash verified.")
# 3) Verify the salt commitment and the value anchored on-chain (v2 only)
if version == "v2":
assert (
hashlib.sha256(bytes.fromhex(data["seed_salt"])).hexdigest()
== data["seed_commitment"]
), "Salt does not match its commitment!"
deck_commitment = hashlib.sha256(
bytes.fromhex(data["deck_hash"]) + bytes.fromhex(data["seed_commitment"])
).hexdigest()
assert deck_commitment == data["deck_commitment"], "Deck commitment mismatch!"
anchored = deck_commitment
print("Salt commitment and deck commitment verified.")
else:
anchored = data["deck_hash"]
# 4) Verify the on-chain event ID
event_id = "0x" + hashlib.sha256(data["pack_event_id"].encode("utf-8")).hexdigest()
assert event_id == data["vrf_event_id"], "VRF event ID mismatch!"
print(f"VRF event ID verified โ look it up at {data['vrf_consumer_address']} "
f"on {data['vrf_network']}.")
print(f"The contract should store {anchored} as this event's deckHash.")
# 5) Reproduce every pull
if version == "v2":
seed = hashlib.sha256(
bytes.fromhex(data["random_seed"]) + bytes.fromhex(data["seed_salt"])
).digest()
tag = b"pack_open_v2"
else:
seed = bytes.fromhex(data["random_seed"])
tag = b"pack_open_v1"
def select_index(seed, tag, sequence_index, user_id, remaining_count):
h = hashlib.sha256()
h.update(seed)
h.update(int(sequence_index).to_bytes(8, "big", signed=False))
h.update(str(user_id).encode("utf-8"))
h.update(tag)
return int.from_bytes(h.digest(), "big") % remaining_count
remaining = list(deck_snapshot)
for pull in data["pulls"]:
idx = select_index(
seed, tag, pull["sequence_index"], pull["user_id"], len(remaining)
)
chosen = remaining.pop(idx)
assert chosen["pack_card_id"] == pull["pack_card_id"], (
f"Mismatch at sequence_index {pull['sequence_index']}"
)
card = pull.get("card") or {}
print(f"#{pull['sequence_index']:>3} ok {card.get('title')}")
print(f"\nAll {len(data['pulls'])} pulls verified.")
Tip: This script is intentionally conservative and does not depend on any GalaxyGrails-specific client libraries. It only uses
requests,hashlib, andjson.
Summaryโ
- Use
GET https://api.galaxygrails.io/v1/pack-events/{PACK_EVENT_ID}/fairnessto fetch fairness data. - Use
deck_snapshotanddeck_hashto verify the deck commitment, andvrf_event_idto find the event on-chain. - For a version 2 event, check
SHA256(seed_salt) == seed_commitment, rebuilddeck_commitmentasSHA256(deck_hash || seed_commitment), and compare that against the contract rather than the bare deck hash. - Re-derive each pull with
SHA256(seed || uint64_be(sequence_index) || user_id || tag) mod remaining, where the seed isSHA256(random_seed || seed_salt)and the tag is"pack_open_v2"for version 2, or the raw seed and"pack_open_v1"for version 1. - Compare your reconstruction against the published
pullstimeline.
Together, these tools let you independently confirm that each GalaxyGrails event is consistent with its pre-committed deck, its pre-committed salt, and Chainlink VRF-powered randomness. For a version 2 event they also confirm that the outcome was fixed before the event opened and could not be computed by anyone, us included, until the salt was published.