Skip to main content

Break fairness

Every Break on GalaxyGrails.io is built to be provably fair: the random number that picks your card comes from an outside source, not from us, and anyone can check any pull afterward. This page covers Breaks. Packs are drawn under their own recipe and have a page of their own: see Pack fairness. The Fairness & Transparency page is the overview of both.

Randomness provided by

The short versionโ€‹

For every Break we create now (fairness version 3):

  • The cards are locked in before anyone buys. Every Break starts with a fixed list of cards. Before the first pull goes on sale, a fingerprint of that list is recorded on a public blockchain, a shared record that anyone can read and nobody, including us, can edit afterward. If a card were added, removed or swapped later, the fingerprint would no longer match.
  • The random pick happens when your pull opens, not before. When your pull opens, an outside service called Chainlink produces a fresh random number for that one pull. The number does not exist until that moment.
  • Nobody can know or influence your pull in advance, and that includes us. When you buy a pull, there is nothing to know yet. Galaxy Grails holds no secret that decides which card you get. The number comes from Chainlink, it is recorded once on the blockchain, and it cannot be replaced or drawn again.
  • Every card is equally likely. Your card is picked from the cards still left in the Break, and each one has the same chance. Price, tier and rarity play no part.
  • You can check any pull yourself. Every pull in the app has a proof panel that checks it automatically, and a Check against Arbitrum button reads the random number straight from the public record.

This applies to every Break format: standard and Head-to-Head. Coin Flips and Sellout Breaks, now retired, were drawn the same way, and any that ran can still be checked.

What happens when you open a pullโ€‹

This is how a Break that carries fairness version 3 works. We have used version 3 since September 30, 2026. Older Breaks used earlier versions of the system, described in the version history below. A Break's version is fixed before its pulls go on sale, is published with its fairness data, and never changes.

  1. Before sales open, we fix the Break's card list and record its fingerprint on the Arbitrum One blockchain. It can be written once and never changed. Only then do pulls go on sale.
  2. You buy a pull. Nothing about your card has been decided. The random number that will pick it does not exist yet.
  3. Your pull opens. We ask Chainlink for a random number for that one pull. Chainlink produces it and delivers it straight to our public record on Arbitrum One. This usually takes about 2 seconds.
  4. Your card is picked using that number and the list of cards still left in the Break.
  5. The result is permanent. Each pull's number can be requested once and recorded once, so a pull cannot be re-rolled. Our record on Arbitrum One only accepts numbers from Chainlink, so nothing else can be swapped in.

In a Head-to-Head, the two pulls share one request that returns two numbers, one for each pull.

If Arbitrum or Chainlink is slow, your pull waits. It is never changed or drawn again. If a random number never arrives, the purchase is canceled and what you paid is returned to your account balance, as the Terms of Service describe.

Checking a pullโ€‹

Open any pull in the app and look for its proof panel. The panel checks the pull automatically in your browser: it takes the random number and the Break's card list and confirms they lead to the card you were given. The Check against Arbitrum button goes one step further and reads the random number from the public record itself.

If you want to look at the record directly, it is public on Arbiscan at 0x6307958ab4c4728Da9C2599717Ade879BD72cF60, and its code is verified on Arbiscan and Sourcify.

While a Break runs, you can see what has been pulled and what remains through live odds and EV tracking. After it ends, you can revisit it under Fairness and replay to step through the full pull timeline and inspect the fairness data, with links to the public record that locked the Break in. Completed Breaks and their fairness reports are listed at https://galaxygrails.io/events.

For most collectors, that is all you need to know: the cards are fixed before sales open, the random pick is made by Chainlink when your pull opens, nobody can know it before then, and you can check it afterward.

The rest of this page is the developer guide: the exact API, the cryptography, and Python verification code that reproduces every pull in a Break from the published data.

Version historyโ€‹

Every Break version has used the same two building blocks: the card list is locked on a public blockchain before anyone buys, and the random numbers come from Chainlink, an outside service. What has changed is when the random number is drawn. Every Break keeps the version it went on sale with and can be checked with that version's recipe, which the developer guide below keeps for each one.

Version 3: a fresh random number for every pull (in use since September 30, 2026)โ€‹

The version described on this page. Each pull gets its own Chainlink random number on the Arbitrum One blockchain, drawn at the moment the pull is opened, delivered in about 2 seconds, and recorded once on a public record that cannot be changed. Moving from one number per Break to one per pull, drawn at open, is the simplest rule we can follow and the easiest to check, and Arbitrum makes it fast.

Version 2: one number per Break, revealed at the end (Polygon)

Each Break got one Chainlink random number on the Polygon blockchain, and every pull in the Break was worked out from it. A second value, the salt, was mixed in and published when the Break ended, with its fingerprint locked on the blockchain beforehand. Once published, every pull in the Break replays exactly, and every version 2 Break can be checked that way today.

Version 1: one number per Break (Polygon)

Each Break got one Chainlink random number on the Polygon blockchain, and every pull in the Break was worked out from it. Anyone can replay every pull in a version 1 Break, and each one can still be checked.


Developer guideโ€‹

This part of the page is for developers and users who want to understand, inspect, and independently verify the fairness model used for Breaks, with concrete API details and working verification code. Details of Pack verification are on the Pack fairness page.


In plain wordsโ€‹

Before you get to the code, here is what the system does and what you can check.

Every Break starts with a fixed list of cards. Before the first pull sells, a fingerprint of that list is written to a public blockchain, where it can be written once and never changed. That is what makes "the cards were locked in before anyone bought" a checkable claim rather than a promise: you can fetch the list, compute the fingerprint yourself, and compare it against what is on the chain.

When a pull is opened, the card is picked using a random number. For current Breaks, that number comes from Chainlink, an outside service that produces randomness the chain can verify, and it is requested only at the moment the pull opens. It does not exist when the pull is bought, so nobody, Galaxy Grails included, can know or influence the pick in advance. The number is stored once on the chain and cannot be replaced.

The rule that turns a random number and the remaining cards into one chosen card is public and deterministic. Given the published data, you can rerun it for every pull in a Break and confirm it lands on the card that was actually revealed. The proof panel in the app does exactly that in your browser; the rest of this page shows how to do it yourself.

Earlier Breaks used one random number for the whole Break instead of one per pull. They are still checkable, with a slightly different recipe, and this page keeps those recipes under Earlier versions at the end, one expandable block per version.


Overviewโ€‹

There are three versions of the fairness algorithm. Each Break's version is fixed before its pulls go on sale, published as fairness_algo_version, and never changes afterwards.

  • Version 3 (in use since September 30, 2026; read fairness_algo_version, not the date): the deck is committed on Arbitrum One before any pull sells, and every pull gets its own Chainlink VRF v2.5 random word, requested at the moment the pull is opened. Nobody, Galaxy Grails included, can know or influence a pull at purchase time, and there is no server-held secret. See Version 3.
  • Versions 1 and 2 (earlier Breaks): one Chainlink VRF seed per Break through a consumer contract on Polygon mainnet, with a per-Break secret salt under version 2. These Breaks stay verifiable with their own recipe, described under Earlier versions at the end of this page.

All three versions share:

  • Chainlink VRF (Verifiable Random Function, v2.5) for on-chain randomness
  • A pre-committed deck snapshot and SHA-256 deck hash, submitted on-chain before any pull sells
  • A deterministic, unweighted selection rule over the cards still left in the deck

This guide walks through the public fairness endpoint and shows how to:

  • Fetch fairness data for a Break
  • 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 Breaks 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.


Version 3: a random word for every pullโ€‹

Breaks that carry version 3 (fairness_algo_version is "v3") are the ones we began creating on September 30, 2026; a few Breaks created earlier that day still carry version 2. Older Breaks keep the version they went on sale with, and the Earlier versions section at the end of this page still applies to them.

Version 3 removes the Break-wide seed and the salt entirely. Instead:

  • The deck is committed before any pull sells. The deck hash is written to the GalaxyGrailsPullVRF contract on Arbitrum One with createEvent(bytes32 eventId, bytes32 deckHash). That write is write-once: a Break's committed deck can never be replaced. For a version 3 Break the anchored value is the bare deck_hash, so the API's deck_commitment equals deck_hash.
  • Each pull gets its own Chainlink VRF v2.5 random word, requested when the pull is opened, not when it is bought. At purchase time the word for your pull does not exist yet, so nobody, Galaxy Grails included, can know or influence which card it will select.
  • Each pull is keyed on-chain by a pull key. A key can be requested once, and its word can be stored once. A pull cannot be re-rolled.
  • The randomness source is fixed. The contract's Chainlink VRF coordinator address is immutable, so no other source of randomness can be substituted. On Arbitrum One that coordinator is 0x3C0Ca683b403E37668AE3DC4FB62F4B29B6f7a3e.
  • A Head-to-Head pair is one request that returns two words, one per pull. Each pull still has its own key, word and sequence number.
  • There is no secret. A word is public on-chain the moment Chainlink delivers it, and the pull is a pure function of that word and the published deck. Nothing is withheld until the Break ends, so a version 3 Break can be verified pull by pull while it is still running.

The contract address, the recipe and a full verification script follow below.

Timing and delaysโ€‹

The time from opening a pull to its result is typically about 2 seconds. If Arbitrum or Chainlink is delayed, the pull waits for its word. It is never changed or re-drawn in the meantime. If a word never arrives, the purchase is canceled and what was paid is returned to the buyer's account balance, as the Terms of Service describe.

The version 3 recipeโ€‹

Pull key. Each pull is requested and stored on-chain under:

pull_key = SHA256( utf8( "{pack_event_id}:{queue_item_id}" ) )

Both ids are the lowercase, hyphenated 36-character UUID text, joined by a single colon. The result is published as 64 hex characters with no 0x prefix. Rebuild it yourself from the two published ids rather than trusting the published key, since that is what ties a word to one specific pull.

Selection index. For each pull:

R   = int.from_bytes(
SHA256( word32
|| uint64_be(sequence_index)
|| b"pack_open_v3" ),
byteorder="big")

idx = R mod len(remaining_cards)
  • word32 is the pull's 32-byte VRF word, decoded from hex. Hash the raw bytes, not the hex text.
  • sequence_index is the pull's published sequence number, encoded as 8 bytes, big-endian, unsigned.
  • b"pack_open_v3" is a literal domain separator, appended last.
  • remaining_cards is the Break's deck_snapshot, in snapshot order, with every card already pulled removed. This is the same pool rule as versions 1 and 2.

In Python:

import hashlib


def pull_key(pack_event_id, queue_item_id):
raw = f"{pack_event_id}:{queue_item_id}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()


def select_index_v3(word_hex, sequence_index, remaining_count):
h = hashlib.sha256()
h.update(bytes.fromhex(word_hex))
h.update(int(sequence_index).to_bytes(8, "big", signed=False))
h.update(b"pack_open_v3")
return int.from_bytes(h.digest(), "big") % remaining_count

Three differences from versions 1 and 2 matter if you are reimplementing this:

  • The input is a per-pull word, not one seed for the whole Break, and there is no salt.
  • The buyer's user_id is not an input. Each pull already has its own word, so it would add nothing. The version 3 proof publishes a username for display, and no buyer id.
  • The separator is pack_open_v3. A version 3 Break replayed with the version 1 or 2 rule reproduces nothing, and the reverse is also true.

Version 3 fields in the fairness payloadโ€‹

A version 3 Break is served by the same public fairness endpoint. Its payload has random_seed, random_seed_source, seed_salt and seed_commitment set to null, since those belong to versions 1 and 2, and adds:

  • deck_snapshot: published while the Break is active, not only after it ends. Under version 3 the word for a pull does not exist until the pull is opened, so knowing the deck order gives nothing away.
  • draws: one entry per pull the Break has opened, in sequence_index order. Each entry carries:
    • sequence_index, status (finalized, pending or void) and, for a void, void_reason
    • queue_item_id and pull_key, so you can rebuild the key
    • request_tx_hash and vrf_request_id: the Arbitrum transaction that requested the word, and Chainlink's request id
    • word, as 64 hex characters with no 0x prefix, plus word_source, fulfillment_block_number and fulfillment_block_hash
    • pack_card_id, card_token_id, card, username and pulled_at for the card the pull produced
  • vrf_anchor_tx_hash and vrf_anchored_at: the Arbitrum transaction that committed the deck with createEvent, and when it was recorded.
  • selection_recipe: the selection rule above, in words.

A pending draw is still waiting for its word, and its word and card are null until the word arrives. A void draw produced no card; void_reason says why. It keeps its sequence number, so the gap stays visible, and it removes no card from the deck.

vrf_event_id is computed the same way as for earlier versions, "0x" + sha256(pack_event_id), and is the key the deck is anchored under on Arbitrum.

Reading the version 3 contractโ€‹

The production contract:

NetworkArbitrum One
ContractGalaxyGrailsPullVRF at 0x6307958ab4c4728Da9C2599717Ade879BD72cF60, source verified on Arbiscan and Sourcify
Chainlink VRF coordinator0x3C0Ca683b403E37668AE3DC4FB62F4B29B6f7a3e

As with earlier versions, always read the contract address and network from the Break's own fairness payload (vrf_consumer_address and vrf_network) rather than assuming them.

GalaxyGrailsPullVRF exposes these read-only getters:

function events(bytes32 eventId) external view returns (bytes32 deckHash);
function getPull(bytes32 pullKey) external view returns (
bytes32 eventId, bool requested, bool fulfilled, uint256 word, uint256 requestId
);
function getWord(bytes32 pullKey) external view returns (uint256);
function isRequested(bytes32 pullKey) external view returns (bool);

events(eventId) returns the anchored deck hash, which should equal the API's deck_hash. getPull(pullKey) returns the word stored for that pull. Use its fulfilled flag rather than a zero word to tell whether the word has arrived, since zero is a valid word. You can call these through any Arbitrum One RPC endpoint.

Verify it in the appโ€‹

Every version 3 pull has a proof panel in the app. It verifies the pull automatically in your browser: it rebuilds the pull key, recomputes the selection index from the published word and deck, and checks that the card it lands on is the card that was recorded. Its Check against Arbitrum button reads the word from the contract, so the word itself is not taken on trust either.

Full version 3 verification exampleโ€‹

This script checks the deck commitment on-chain, rebuilds every pull key, reads each word from the contract, and replays every pull. Because version 3 publishes everything as it happens, you can run it against a Break that is still live.

import hashlib
import json

import requests
from web3 import Web3

BREAK_ID = "<Break UUID>" # Replace with your Break's ID
RPC_URL = "https://<your-arbitrum-one-rpc>"

resp = requests.get(
f"https://api.galaxygrails.io/v1/breaks/{BREAK_ID}/fairness"
)
resp.raise_for_status()
data = resp.json()
assert data["fairness_algo_version"] == "v3", "Not a version 3 Break."

# 1) The deck hash, and the value anchored on Arbitrum
deck = data["deck_snapshot"]
serialized = json.dumps(deck, sort_keys=True, separators=(",", ":"))
assert hashlib.sha256(serialized.encode("utf-8")).hexdigest() == data["deck_hash"]

event_id = "0x" + hashlib.sha256(data["pack_event_id"].encode("utf-8")).hexdigest()
assert event_id == data["vrf_event_id"]

ABI = [
{"name": "events", "type": "function", "stateMutability": "view",
"inputs": [{"name": "", "type": "bytes32"}],
"outputs": [{"name": "", "type": "bytes32"}]},
{"name": "getPull", "type": "function", "stateMutability": "view",
"inputs": [{"name": "pullKey", "type": "bytes32"}],
"outputs": [{"name": "eventId", "type": "bytes32"},
{"name": "requested", "type": "bool"},
{"name": "fulfilled", "type": "bool"},
{"name": "word", "type": "uint256"},
{"name": "requestId", "type": "uint256"}]},
]
w3 = Web3(Web3.HTTPProvider(RPC_URL))
contract = w3.eth.contract(
address=Web3.to_checksum_address(data["vrf_consumer_address"]), abi=ABI
)
anchored = contract.functions.events(bytes.fromhex(event_id[2:])).call()
assert bytes(anchored).hex() == data["deck_hash"], "Anchored deck hash mismatch!"
print("Deck hash verified on Arbitrum.")


# 2) Replay every draw in sequence order
def select_index_v3(word_hex, sequence_index, remaining_count):
h = hashlib.sha256()
h.update(bytes.fromhex(word_hex))
h.update(int(sequence_index).to_bytes(8, "big", signed=False))
h.update(b"pack_open_v3")
return int.from_bytes(h.digest(), "big") % remaining_count


remaining = list(deck)

for draw in sorted(data["draws"], key=lambda d: d["sequence_index"]):
seq = draw["sequence_index"]
if draw["status"] == "void":
print(f"#{seq:>3} void ({draw['void_reason']}), no card drawn")
continue
if draw["status"] != "finalized":
print(f"#{seq:>3} waiting for its word; stopping here")
break

key = hashlib.sha256(
f"{data['pack_event_id']}:{draw['queue_item_id']}".encode("utf-8")
).hexdigest()
assert key == draw["pull_key"], f"Pull key mismatch at #{seq}"

_, _, fulfilled, word_onchain, _ = contract.functions.getPull(
bytes.fromhex(key)
).call()
assert fulfilled, f"No word on-chain for #{seq}"
assert format(word_onchain, "064x") == draw["word"], f"Word mismatch at #{seq}"

idx = select_index_v3(draw["word"], seq, len(remaining))
chosen = remaining.pop(idx)
assert chosen["pack_card_id"] == draw["pack_card_id"], f"Card mismatch at #{seq}"

card = draw.get("card") or {}
print(f"#{seq:>3} ok {card.get('title')}")

print("Every finalized pull verified.")

Public Fairness Endpointโ€‹

URLโ€‹

GET https://api.galaxygrails.io/v1/breaks/{BREAK_ID}/fairness
  • Authentication: Not required (public)
  • Path params:
    • BREAK_ID: UUID of the Break (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 Breaksโ€‹

For a version 1 or version 2 Break, the endpoint behaves differently depending on whether the Break is still live. (A version 3 Break has nothing to withhold: its deck_snapshot and draws are published while it runs. See Version 3 fields in the fairness payload.)

  • While the Break 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 and its random_seed_source (both null)
      • The seed_salt (null)
      • The full deck_snapshot (null)
      • The full pulls array (returned as [])
    • This prevents leaking information about future pulls while still letting users verify the on-chain commitments.
  • After the Break 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 (price, stop_ev_buffer, deck hash, commitments, fairness_algo_version, VRF metadata) are always present as keys, though a value can be null when it does not apply, such as seed_commitment on a version 1 Break.

Two edge cases to handle:

  • A Break that is still awaiting approval reports is_active: false but has no fairness data yet: no seed, no deck hash and no commitments. Check that random_seed is present before treating an inactive Break as finished.
  • Responses are cached briefly on each API server, for up to 60 seconds for an inactive Break. Right after a Break ends, the seed, salt and pulls can take up to a minute to appear.

The commitments are deliberately published while the Break 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 Break)โ€‹

{
"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 when the Break ends (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 Break's 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": { "large": "https://..." }, // Image URLs keyed by size
"back_image": { "large": "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 Break 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 Break's starting expected value, not of a pull's price. The Break 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 (a card with no initial_price counts at its current market price). Head-to-Head Breaks skip this check and run until every pull is opened. Sellout Breaks and Coin Flips, which are retired, did the same, and any that ran can still be replayed.
  • fairness_algo_version: Version identifier for the selection algorithm: v1, v2 or v3. It is fixed before the Break's pulls go on sale and never changes afterwards, so a Break 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 a Break whose seed fails that check never opens for sale.
  • seed_salt: The Break's 32-byte secret, as a 64-character hex string. Version 2 only. It is null for the whole time the Break is live and is published when the Break 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 (null for version 1). Public from the moment the Break is created, which is what makes the later reveal meaningful.
  • deck_commitment: The exact value this Break wrote on-chain as the deckHash argument of createEvent, as hex. For version 2 it is SHA256(deck_hash || seed_commitment), computed over the raw bytes of both, so it deliberately differs from deck_hash. For version 1 it equals deck_hash, since that is what version 1 anchors. Always compare the contract against deck_commitment, not deck_hash.
  • random_seed_source: vrf for Breaks seeded by Chainlink VRF. Hidden (null) while the Break is active.
  • 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, as shown 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 Break was anchored on, recorded at the time it was submitted. Version 1 and version 2 Breaks are anchored on Polygon mainnet (vrf_network is "polygon-mainnet"), and version 3 Breaks on Arbitrum One. The consumer has been redeployed before, so these are historical facts about the Break rather than a description of today's configuration, and an older Break correctly reports the older contract. Always look a Break up at the address its own payload gives you.

Which Breaks are replayableโ€‹

Use the public Break listing at https://galaxygrails.io/events. It contains the Breaks that replay from their published fairness data, so if you are enumerating Breaks to verify, everything you find there will reproduce.

The same listing is available from the API, with no authentication:

GET https://api.galaxygrails.io/v1/breaks/history?page=1&pageSize=50

It returns {"breaks": [...], "meta": {"page", "pageSize", "pageCount", "total"}}, newest first, with at most 50 Breaks per page. Each entry's id is the BREAK_ID for the fairness endpoint above. It lists Breaks that can be replayed: for versions 1 and 2, ended Breaks that had at least one pull, carry a seed and a supported fairness_algo_version, and, for version 2, have published their salt; for version 3, Breaks whose deck is anchored on-chain and that have at least one finalized pull, whether or not the Break has ended. Any Break can also be fetched by ID. Verify version 3 Breaks with the version 3 recipe.

Fairness payloads also remain fetchable by ID for every Break ever run, including early ones that predate the current fairness system. If you have reached a Break by ID rather than through the listing, check before attempting a replay that:

  • fairness_algo_version is "v1" or "v2" (for "v3", use the version 3 recipe instead),
  • random_seed is present,
  • and, when the version is "v2", seed_salt is present too.

The pool only ever shrinks: a card leaves remaining_cards when it is pulled and never returns to it for the rest of the Break, including when it is later sold back through Buyback.

If a Break you expected to replay does not, contact [email protected] with the Break ID and we will look into it with you.


Verifying the Deck Commitment (all versions)โ€‹

The deck_hash is a SHA-256 hash of the serialized deck_snapshot. This check is the same for every version. 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 Break is exactly the one committed via deck_hash. And because that hash was written on-chain before the Break's pulls went on sale (directly for versions 1 and 3, or bound into deck_commitment for version 2), exactly the one committed publicly before any pull 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 vrf_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 Break 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.


Summaryโ€‹

  • Use GET https://api.galaxygrails.io/v1/breaks/{BREAK_ID}/fairness to fetch fairness data.
  • Use deck_snapshot and deck_hash to verify the deck commitment, and vrf_event_id to look the Break up on the contract named by vrf_consumer_address and vrf_network.
  • For a version 3 Break, rebuild each pull key as SHA256("{pack_event_id}:{queue_item_id}"), read that pull's word from GalaxyGrailsPullVRF on Arbitrum One with getPull, and re-derive the card with SHA256(word32 || uint64_be(sequence_index) || "pack_open_v3") mod remaining, walking draws in sequence order.
  • For an earlier Break, read the Break's deckHash and seed from the contract on Polygon. For a version 2 Break, 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.
  • For an earlier Break, 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 draws (version 3) or pulls (earlier versions) timeline.

Together, these tools let you independently confirm that each GalaxyGrails Break is consistent with its pre-committed deck and Chainlink VRF-powered randomness. For a version 3 Break they confirm that each pull's word was delivered by Chainlink to the contract, stored once, and used exactly as published. For a version 2 Break they also confirm that the salt was committed before the seed existed and that the public data could not predict any pull until the salt was published.


Earlier versionsโ€‹

Breaks created before version 3 keep the version they went on sale with and remain verifiable with that version's recipe. Expand the version that applies to your Break: read its fairness_algo_version to find out which.

Version 2 (earlier Breaks): one seed per Break, plus a salt

Versions 1 and 2: one seed per Break

Under versions 1 and 2, the key idea is:

Before a single pull is sold, the deck is fixed and a commitment to it is written on-chain, and a Chainlink VRF seed is committed for the Break. 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.

How the three versions differโ€‹

Under version 1, the Break's Chainlink VRF seed is public on-chain once fulfilled, and every pull is derived from it. That is what makes every version 1 Break replayable from public data alone.

Version 2 changes how each pull is derived by mixing in a per-Break secret, the salt, that is published only when the Break ends. Selection uses an effective seed hashed from the VRF output and the salt together (the exact formula is under The selection rule). While the Break runs the salt is withheld, so which card comes next cannot be computed from the public data. When the Break 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.

Version 3 changes the unit of randomness rather than the derivation: there is no Break-wide seed and no salt, and each pull is its own VRF request, sent when the pull is opened and answered in about two seconds on Arbitrum One. One word per pull is the simplest recipe to publish and to replay, and it needs no reveal step. Version 1 and version 2 Breaks are unchanged and remain fully verifiable under their own rules.

The version a Break used is published as fairness_algo_version, and it is fixed before the Break's pulls go on sale and never changes afterwards. Do not assume a Break's version from its date: always read the field.

The same salt, derivation and reveal apply to every Break format (Standard and Head-to-Head, plus the retired Coin Flip and Sellout Break). There is no per-format branch in how a card is selected.

Fairness Algorithm (versions 1 and 2)โ€‹

Each version 1 or version 2 Break follows this pattern:

  1. Fix the deck: Before sales start, the full list of cards for the Break 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 Break, and its commitment SHA256(salt) is published as seed_commitment. The salt itself stays secret until the Break ends. (Version 2 only.)
  3. Commit on-chain: A single value is submitted to the VRF consumer contract on Polygon together with a derived on-chain ID (vrf_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 256-bit random word, which the contract stores as that Break's seed (random_seed). Pulls cannot be sold until the seed is committed (and, under version 2, until the salt is in place too).
  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 Break, and the buyer's user ID selects one card from those still unpulled.
  6. Reveal over time: As pulls are opened, each selected card is revealed live.
  7. Publish the salt: When the Break ends, seed_salt is published alongside the seed, the deck snapshot and the pulls, and the whole Break becomes replayable. (Version 2 only.)
What is anchored on-chain is not the bare deck hash

For a version 2 Break, 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.

Because the deck snapshot, deck hash, salt, VRF seed, and pulls are all exposed via the fairness API once the Break 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 (salted):

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)

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 Break 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 normally the pull's 1-based position in the Break, and it is published on every pull. Always use the published value rather than counting positions yourself (see the Head-to-Head note under Reproducing the Pull Order).
  • 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.)

Replay is guaranteed by constructionโ€‹

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. Pulls cannot even be sold on a Break whose seed is missing or malformed, or, for version 2, whose salt is. Replay is guaranteed by construction.

A version 2 Break 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.

Once a version 2 Break has ended and its salt is public, it can never be reopened: the platform refuses to reactivate a Break whose salt has been published, because every remaining pull would then be computable from public data.

Reading the contract directly (versions 1 and 2)โ€‹

For a version 1 or version 2 Break, the Polygon consumer contract exposes two read-only getters, keyed by vrf_event_id. (For version 3, see Reading the version 3 contract.)

function events(bytes32 eventId) external view returns (bytes32 deckHash, uint256 seed);
function getSeed(bytes32 eventId) external view returns (uint256);

deckHash is the value anchored by createEvent and is never changed afterwards. seed is 0 until Chainlink fulfills the request, and then holds the random word. You can call them through any Polygon RPC endpoint. A minimal web3.py example:

from web3 import Web3

ABI = [{
"name": "events",
"type": "function",
"stateMutability": "view",
"inputs": [{"name": "", "type": "bytes32"}],
"outputs": [
{"name": "deckHash", "type": "bytes32"},
{"name": "seed", "type": "uint256"},
],
}]

w3 = Web3(Web3.HTTPProvider("https://<your-polygon-rpc>"))
contract = w3.eth.contract(
address=Web3.to_checksum_address(data["vrf_consumer_address"]), abi=ABI
)
deck_hash_onchain, seed_onchain = contract.functions.events(
bytes.fromhex(data["vrf_event_id"][2:])
).call()

# The anchored value must be the API's deck_commitment (for v1, equal to deck_hash).
assert bytes(deck_hash_onchain).hex() == data["deck_commitment"]

# random_seed is the on-chain seed as zero-padded 64-character hex, no 0x prefix.
assert format(seed_onchain, "064x") == data["random_seed"]

The second assertion only applies once the Break has ended, since random_seed is withheld until then.

Verifying the salt commitment and the on-chain value (version 2)โ€‹

For a version 2 Break, 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 Break. The bare deck_hash will not match on-chain for a version 2 Break, 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 Break, 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 (versions 1 and 2)โ€‹

This is the core of verification for a version 1 or version 2 Break. (A version 3 Break walks draws instead of pulls; see The version 3 recipe.) 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 Break'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 Break 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 Break format. Standard and Head-to-Head Breaks, and the retired Coin Flips and Sellout Breaks, all draw from the same shrinking pool with the same rule. Standard, Coin Flip and Sellout Break number their pulls 1..N.
  • Head-to-Head Breaks number their two pulls from the pair itself. If opening the pair is interrupted after the first pull and then retried, the second pull is also published with sequence_index 1, and it was drawn with that value from the one remaining card. Replaying pulls[] in the order the API returns it (by sequence_index, then pulled_at), with each pull's own published sequence_index, reproduces this case exactly, as the code above does.

Full End-to-End Verification Example (versions 1 and 2)โ€‹

A complete script you can adapt. For a version 3 Break, use the version 3 example instead. It:

  1. Fetches fairness data for a completed Break.
  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 vrf_event_id, the Break's on-chain ID.
  5. Reproduces every pull and compares against the published timeline.

It handles versions 1 and 2, so you can point it at any Break in the listing.

import hashlib
import json

import requests

BREAK_ID = "11655b60-f6cc-40a8-b8a8-0282c4e5d9f1" # Replace with your Break's ID

# 1) Fetch fairness JSON (returned as a top-level object, no envelope)
resp = requests.get(
f"https://api.galaxygrails.io/v1/breaks/{BREAK_ID}/fairness"
)
resp.raise_for_status()
data = resp.json()

if data["is_active"]:
raise SystemExit("Break 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 Break was not seeded for deterministic replay.")

if version == "v2" and not data.get("seed_salt"):
raise SystemExit("Version 2 Break 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 vrf_event_id, the Break's on-chain 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 Break'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.

Version 1 (earliest Breaks): one seed per Break, no salt

Version 1 is version 2 without the salt. The effective seed is the raw VRF seed, the domain tag is pack_open_v1, and the deck commitment is the bare deck_hash. The contract getters and the replay recipe are the same as version 2's, and the code in the version 2 block handles both versions: see Reading the contract directly, Reproducing the Pull Order and the full end-to-end example.

Existing version 1 Breaks are unchanged and still replay under the version 1 rule.

For a version 1 Break, the anchored value is the bare deck_hash, so the API reports deck_commitment equal to deck_hash, and seed_commitment is null.

Under version 1 the selection rule is:

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)