Skip to main content

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:

  1. Fix the deck โ€“ Before sales start, the full list of cards for the event is locked into a snapshot and hashed (the deck_hash).
  2. Generate and commit the salt โ€“ A 32-byte secret salt is generated for the event, and its commitment SHA256(salt) is published as seed_commitment. The salt itself stays secret until the event ends. (Version 2 only.)
  3. 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 the deck_commitment for version 2, or the bare deck_hash for version 1, and the same call requests the randomness. The commitment is public and immutable from that point on.
  4. 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.
  5. 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.
  6. Reveal over time โ€“ As packs are opened, each selected card is revealed live.
  7. Publish the salt โ€“ When the event ends, seed_salt is published alongside the seed, the deck snapshot and the pulls, and the whole event becomes replayable. (Version 2 only.)
What is anchored on-chain is not the bare deck hash

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_index is 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 and b"pack_open_v2" for version 2. Read fairness_algo_version to 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_salt is a 64-character hex string like random_seed. Concatenate the two as raw bytes, not as text: hashing the hex characters gives a different and wrong answer.
  • The buyer's user_id is 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 the pulls[] array publishes user_id so 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_version is "v1" or "v2",
  • random_seed is present,
  • and, when the version is "v2", seed_salt is 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_commitment and deck_commitment), but hides:
      • The actual random_seed
      • The seed_salt
      • The full deck_snapshot
      • The full pulls array (returned as [])
    • This prevents leaking information about future pulls while still letting users verify the on-chain commitments.
  • After the event ends (is_active = false):

    • The response includes the full fairness payload:
      • random_seed
      • random_seed_source
      • seed_salt
      • deck_snapshot
      • pulls (ordered timeline)

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 below starting_EV ร— stop_ev_buffer. EV on both sides is the mean initial_price across 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, either v1 or v2. 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 no 0x prefix. 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 is null for the whole time the event is live and is published when the event ends. Hash it and compare against seed_commitment to 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 the deckHash argument of createEvent, so it is the one to compare against the contract, not deck_hash.
  • random_seed_source: vrf for events seeded by Chainlink VRF.
  • vrf_event_id: "0x" + sha256(pack_event_id), where pack_event_id is the UUID in its standard lowercase hyphenated string form. You can recompute this yourself โ€” see below.
  • vrf_request_id: The transaction hash of the createEvent call that requested randomness. This is not the Chainlink requestId.
  • vrf_consumer_address and vrf_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=True sorts the keys within each object; it does not sort the list itself. The order of deck_snapshot is 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_snapshot entries contain only pack_card_id, card_inventory_id, and initial_price โ€” there is no card identity in the snapshot. To see which card each index corresponds to, join through the pulls[] array on pack_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:

  1. Fetches fairness data for a completed event.
  2. Recomputes the deck hash and asserts it matches.
  3. Checks the salt against its commitment, and rebuilds the value anchored on-chain (version 2).
  4. Recomputes the on-chain event ID.
  5. 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, and json.


Summaryโ€‹

  • Use GET https://api.galaxygrails.io/v1/pack-events/{PACK_EVENT_ID}/fairness to fetch fairness data.
  • Use deck_snapshot and deck_hash to verify the deck commitment, and vrf_event_id to find the event on-chain.
  • For a version 2 event, check SHA256(seed_salt) == seed_commitment, rebuild deck_commitment as SHA256(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 is SHA256(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 pulls timeline.

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.