Pack fairness
Every Pack on GalaxyGrails.io is provably fair: the odds are committed publicly before the Pack goes on sale, 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 Packs. Breaks are drawn under their own recipe and have a page of their own: see Break fairness. The Fairness & Transparency page is the overview of both.
Randomness provided by
The short versionโ
- The odds and the cards are locked before anyone buys. Each version of a Pack tier fixes its odds and the cards it can draw. A fingerprint of that version is recorded once on Arbitrum One, a public blockchain where nobody can edit it. If a card or the odds were changed later, the fingerprint would no longer match. The app shows each Pack's odds before you buy, and those are the odds that apply.
- Your purchase is locked too. When you buy, your purchase gets its own write-once record that binds the version you bought and which cards were unavailable at that moment.
- One random number per Pack pull, and no re-rolls. Each Pack pull gets its own Chainlink VRF random number on Arbitrum One. The record accepts it once. It cannot be requested again or replaced.
- Nothing we decide after seeing the number matters. The recipe is committed before the number exists. Your card is worked out from the locked version, your purchase's record and the contract's public logs on Arbitrum One. There is no input left for us to choose.
- You can check any pull yourself. Every Pack pull has a proof page that recomputes the result in your browser, and this page shows how to recompute it with a short script.
What happens when you buy a Packโ
- Before a version goes on sale, we fix its odds and its list of cards, and record its fingerprint on Arbitrum One. It is written once and never changed. A change is a new version with a new fingerprint.
- You buy a Pack. Your purchase is recorded on Arbitrum One with the version you bought and the cards that were already unavailable. The random number that will pick your card does not exist yet.
- We ask Chainlink for a random number for your pull. Chainlink produces it and delivers it to our public record on Arbitrum One. This usually takes about 2 seconds.
- Your card is picked from the version you bought, using that number, your purchase's record and the cards other Pack pulls were awarded in the meantime.
- The result is permanent. Each pull's number can be requested once and recorded once, so a pull cannot be re-rolled.
If Arbitrum or Chainlink is slow, your pull waits. It is never changed or drawn again. If a Pack cannot be drawn, the purchase is canceled and what you paid is returned to your account balance, as the Terms of Service describe.
How a pull is chosenโ
The recipe is named packs_v1. It is frozen: a change would be a new recipe under a new name, and a pull is always checked with the recipe it was drawn under.
- The random number picks a value band, using the odds recorded in the version you bought.
- Inside that band, the cards are put in an order set by the random number, and the first card that is still available is yours.
- A card that another Pack pull has already been awarded is skipped. Which pulls count is decided by the contract's public logs on Arbitrum One, so one card is never awarded twice.
- If every card in the rolled band is already taken, the draw moves to the nearest band above it, then the nearest below, and takes the first available card there. This is part of the frozen recipe, not a choice anyone makes.
Checking a pullโ
Every Pack pull has a proof page at /packs/verify/<pull id>. Your browser rebuilds the locked version from the published data, reads the random number and the contract's logs from Arbitrum One, and replays the recipe in five steps. If all of them hold and the card it arrives at is the one you were given, the page says Verified in your browser. If a step fails, it says Not verified and names the step.
A proof is published once the pull and the pulls around it have settled. Until then the page says it is not ready and checks again on its own.
The contract that holds the records is the same public one Breaks use, at 0x6307958ab4c4728Da9C2599717Ade879BD72cF60 on Arbiscan, and its code is verified on Arbiscan and Sourcify. Chainlink's VRF coordinator for it on Arbitrum One is 0x3C0Ca683b403E37668AE3DC4FB62F4B29B6f7a3e.
Developer guideโ
This part is for developers and users who want to recompute a Pack pull themselves, with the real recipe and a script that uses only Python's standard library.
1. Get the proofโ
GET https://api.galaxygrails.io/v1/packs/pulls/{PULL_ID}/proof
- Authentication: not required (public).
PULL_ID: the id in the proof page's address,/packs/verify/<pull id>.- The proof is returned as the top-level JSON object, with no envelope. It answers
404with the errorproof_not_readyuntil the pull and the pulls around it have settled.
curl -s https://api.galaxygrails.io/v1/packs/pulls/$PULL_ID/proof -o proof.json
The fields the check uses:
| Field | What it is |
|---|---|
target_pull_key | The pull's key on the contract, 64 hex characters. |
award | The card (inventory id) the pull was awarded, or null if the draw was exhausted. |
records | One record per draw the result depends on, keyed by pull key. It holds the target pull and every Pack draw in its window. |
chain_events | The contract's logs for the covered span, as kind anchored, requested or fulfilled, each with block and log_index. |
coverage | from and to, each a block and log_index: the span for which every relevant log is supplied. |
version_root, version_event_id, selection_version | The target version's root, the event id it is anchored under, and its recipe (packs_v1). |
vrf_network, vrf_consumer_address | The chain and contract to read. Use these, not an assumed address. |
version_anchor_tx_hash, purchase_anchor_tx_hash, request_tx_hash | Transactions to open on Arbiscan, where they exist. |
max_depth, verified | The size limit the server used, and the server's own verdict. The script below does not trust the verdict; it recomputes. |
payment_unavailable, payment_unavailable_hash | Informational, for a Pack that waited its turn: the unavailable set when the buyer paid. Never an input to the check. |
Each entry in records carries:
| Field | What it is |
|---|---|
purchase_id | The purchase this draw belongs to. The pull key derives from it. |
version | tier_price_cents, version_no and bands. Each band has odds_ppm, lo_cents, hi_cents and leaves, a list of [inventory id, value_cents] pairs. |
selection_version | The recipe this version was built under. |
committed_unavailable | The cards that were unavailable at the purchase, bound into the purchase's commitment. |
as_of | block and log_index: the chain position that unavailable set was taken at. |
claimed_award | The card this draw was awarded, or null. |
username | A display name. No account id is published. |
Each entry in chain_events is one of:
{"kind": "anchored", "event_id": "...", "root": "...", "block": 0, "log_index": 0}
{"kind": "requested", "pull_key": "...", "event_id": "...", "block": 0, "log_index": 0}
{"kind": "fulfilled", "pull_key": "...", "word": "...", "block": 0, "log_index": 0}
anchored is the contract's EventCreated log (a write-once record), requested is PullRequested, and fulfilled is PullFulfilled, the log that delivers the Chainlink word.
2. The recipe, step by stepโ
All integers are big-endian and unsigned. A UUID is its 16 raw bytes. || means the bytes are joined in that order. Every hash is SHA-256.
The pool version is fingerprinted. Each card is hashed as a leaf, the leaves of a band are ordered by their 16 id bytes and combined into a Merkle root, and the bands are folded into one version root.
leaf = sha256(0x00 || id16 || value_cents u64)
node = sha256(0x01 || left || right) # an odd level repeats its last node
band_root = Merkle root of the band's leaves, ordered by id16 ascending
(a single-card band's root is its leaf; an empty band's root is 32 zero bytes)
version_root = sha256(0x02 || "packs_v1" || tier_price_cents u64 || version_no u64
|| band_count u8
|| for each band, lowest first:
odds_ppm u32 || lo_cents u64 || hi_cents u64
|| band_root(32) || leaf_count u32)
version_event_id = sha256("packs_v1:event" || version_root), as hex
The contract holds version_root under version_event_id, written once.
The purchase is fingerprinted. It binds the version, the cards already unavailable and the chain position that set was taken at.
unavailable_hash = sha256(0x03 || count u32 || each unique id16, ascending)
purchase_commitment = sha256(0x05 || version_root || unavailable_hash
|| as_of_block u64 || as_of_log_index u32)
purchase_event_id = sha256("packs_v1:purchase" || purchase_id16), as hex
pull_key = sha256("packs_v1:pull" || purchase_id16), as hex
The contract holds purchase_commitment under purchase_event_id, and the word is requested under pull_key. Because the pull key comes from the purchase alone and the contract refuses a second request for a key, a purchase has exactly one commitment and one word.
The word picks the card.
roll = int(sha256(word32 || "packs_v1:band")) mod 1000000
band = the first band, lowest first, whose running total of odds_ppm is above the roll
rank(c) = sha256(word32 || "packs_v1:card" || id16 of card c)
word32 is the 32-byte word decoded from hex. Hash the raw bytes, not the hex text. Starting at the rolled band, then each band above it, then each band below it from the nearest down, the award is the card with the smallest rank among those not blocked, in the first band that has one. If no band has one, the draw is exhausted and the award is null.
Blocked cards. A card is blocked if it is in the purchase's committed_unavailable, or if it was awarded to a Pack draw (any tier, any version) whose word landed on Arbitrum after this purchase's as_of position and before this draw's own word. Positions compare as (block, log_index), and two words at one position are ordered by pull key. That is how two buyers opening at the same moment can never receive the same card. Each such draw's award is computed by this same rule, which is why a proof carries a record for every draw in the window.
What a valid proof has to show. For the target and every draw in its window:
- The pull key is the hash of the record's purchase id.
- The request was made against
version_event_id, and the contract holds the version root under that id, written before the request. - The contract holds the purchase commitment under
purchase_event_id, written afteras_ofand before the request. - Every band that has odds still had a card outside the unavailable set, so the odds were honest at sale.
- The word arrived after the request, and replaying the recipe gives the card the record names.
3. Verify it with a scriptโ
Save this as verify_pack.py. It uses only Python's standard library, takes the proof JSON, and recomputes the awarded card with the steps above. It reads no explanation from the proof and does not trust its verified flag.
"""Replay one Galaxy Grails Pack pull from its public proof JSON (recipe packs_v1).
Usage: python3 -I verify_pack.py proof.json
Standard library only.
"""
import hashlib
import json
import sys
import uuid
def sha(data):
return hashlib.sha256(data).digest()
def uid(text):
return uuid.UUID(text).bytes # the 16 raw bytes of a UUID
def u(n, size):
return int(n).to_bytes(size, "big", signed=False)
def qty(x):
return int(x, 16) if isinstance(x, str) else int(x)
def pos(obj):
return (qty(obj["block"]), qty(obj["log_index"]))
# --- commitments -----------------------------------------------------------
def leaf_hash(inv_id, value_cents):
return sha(b"\x00" + uid(inv_id) + u(value_cents, 8))
def band_root(leaves):
level = [leaf_hash(i, v) for i, v in sorted(leaves, key=lambda l: uid(l[0]))]
if not level:
return bytes(32)
while len(level) > 1:
if len(level) % 2:
level.append(level[-1])
level = [sha(b"\x01" + level[i] + level[i + 1]) for i in range(0, len(level), 2)]
return level[0]
def version_root(version):
out = b"\x02packs_v1" + u(version["tier_price_cents"], 8) + u(version["version_no"], 8)
out += u(len(version["bands"]), 1)
for band in version["bands"]:
out += u(band["odds_ppm"], 4) + u(band["lo_cents"], 8) + u(band["hi_cents"], 8)
out += band_root(band["leaves"]) + u(len(band["leaves"]), 4)
return sha(out)
def version_event_id(root):
return sha(b"packs_v1:event" + root).hex()
def unavailable_hash(ids):
raw = sorted({uid(i) for i in ids})
return sha(b"\x03" + u(len(raw), 4) + b"".join(raw))
def pull_key(purchase_id):
return sha(b"packs_v1:pull" + uid(purchase_id)).hex()
def purchase_event_id(purchase_id):
return sha(b"packs_v1:purchase" + uid(purchase_id)).hex()
def purchase_commitment(root, unavail_hash, as_of):
return sha(b"\x05" + root + unavail_hash + u(as_of[0], 8) + u(as_of[1], 4)).hex()
# --- the draw --------------------------------------------------------------
def check_version(version):
bands = version["bands"]
assert sum(b["odds_ppm"] for b in bands) == 1_000_000, "odds must total 1,000,000 ppm"
seen, prev_hi = set(), None
for b in bands:
assert b["lo_cents"] < b["hi_cents"], "band edges"
assert prev_hi is None or b["lo_cents"] >= prev_hi, "bands must be ordered"
prev_hi = b["hi_cents"]
assert b["odds_ppm"] == 0 or b["leaves"], "a band with odds needs a card"
for inv_id, value in b["leaves"]:
assert b["lo_cents"] <= value < b["hi_cents"], "card outside its band"
assert uid(inv_id) not in seen, "duplicate card"
seen.add(uid(inv_id))
def roll_band(word, bands):
wb = bytes.fromhex(word)
roll = int.from_bytes(sha(wb + b"packs_v1:band"), "big") % 1_000_000
total = 0
for index, band in enumerate(bands):
total += band["odds_ppm"]
if roll < total:
return roll, index
raise AssertionError("odds do not total 1,000,000")
def award(word, version, blocked):
wb = bytes.fromhex(word)
_, rolled = roll_band(word, version["bands"])
count = len(version["bands"])
for index in [*range(rolled, count), *range(rolled - 1, -1, -1)]:
free = [i for i, _ in version["bands"][index]["leaves"] if uid(i) not in blocked]
if free:
best = min(free, key=lambda i: (sha(wb + b"packs_v1:card" + uid(i)), uid(i)))
return best, rolled, index
return None, rolled, None # every card is taken: the draw is exhausted
def verify(proof):
target = proof["target_pull_key"]
records, events = proof["records"], proof["chain_events"]
cov_from, cov_to = pos(proof["coverage"]["from"]), pos(proof["coverage"]["to"])
anchored, requested, fulfilled = {}, {}, {}
for e in events:
if e["kind"] == "anchored":
anchored[e["event_id"]] = (pos(e), e["root"])
elif e["kind"] == "requested":
requested[e["pull_key"]] = (pos(e), e["event_id"])
elif e["kind"] == "fulfilled":
fulfilled[e["pull_key"]] = (pos(e), e["word"])
# A draw is a Pack draw when its event id is the id of a root anchored on chain.
pack_events = {
eid for eid, (_, root) in anchored.items() if version_event_id(bytes.fromhex(root)) == eid
}
is_pack = lambda k: k in requested and requested[k][1] in pack_events
order = sorted((fulfilled[k][0], k) for k in fulfilled if is_pack(k))
unclassified = sorted(
(fulfilled[k][0], k)
for k in fulfilled
if k not in requested or requested[k][1] not in anchored
)
def window(key):
own = (fulfilled[key][0], key)
as_of = (records[key]["as_of"]["block"], records[key]["as_of"]["log_index"])
if as_of < cov_from or own[0] > cov_to:
raise ValueError("window outside the declared coverage")
if any(as_of < p and (p, k) < own for p, k in unclassified):
raise ValueError("a fulfillment in the window cannot be classified")
return [k for p, k in order if as_of < p and (p, k) < own]
if target not in fulfilled:
raise ValueError("the target has no word on chain")
# Close over every draw the target depends on.
members, todo = {}, [target]
while todo:
key = todo.pop()
if key in members:
continue
if key not in records or key not in requested:
raise ValueError("a draw in the window has no record or no request")
members[key] = window(key)
todo += members[key]
# Award in chain order, so every window member is solved before it is needed.
awards = {}
for key in sorted(members, key=lambda k: (fulfilled[k][0], k)):
rec = records[key]
blocked = {uid(i) for i in rec["committed_unavailable"]}
blocked |= {uid(awards[m]) for m in members[key] if awards[m] is not None}
awards[key] = award(fulfilled[key][1], rec["version"], blocked)[0]
steps = {}
for key in members:
rec = records[key]
check_version(rec["version"])
root = version_root(rec["version"])
req_pos, event_id = requested[key]
as_of = (rec["as_of"]["block"], rec["as_of"]["log_index"])
commit = purchase_commitment(root, unavailable_hash(rec["committed_unavailable"]), as_of)
p_anchor = anchored.get(purchase_event_id(rec["purchase_id"]))
v_anchor = anchored.get(event_id)
blocked_ids = {uid(i) for i in rec["committed_unavailable"]}
ok = {
"pull key comes from the purchase": pull_key(rec["purchase_id"]) == key,
"request is against this version": event_id == version_event_id(root),
"version root anchored before the request": bool(
v_anchor and v_anchor[1] == root.hex() and v_anchor[0] < req_pos
),
"purchase commitment anchored between as_of and the request": bool(
p_anchor and p_anchor[1] == commit and as_of < p_anchor[0] < req_pos
),
"odds were honest at sale": all(
any(uid(i) not in blocked_ids for i, _ in b["leaves"])
for b in rec["version"]["bands"]
if b["odds_ppm"] > 0
),
"word arrived after the request": req_pos < fulfilled[key][0],
"recorded card matches the replay": rec["claimed_award"] == awards[key],
}
steps[key] = ok
return awards[target], steps[target], all(all(s.values()) for s in steps.values()), len(members)
if __name__ == "__main__":
with open(sys.argv[1]) as fh:
proof = json.load(fh)
got, checks, verified, n = verify(proof)
for name, passed in checks.items():
print(("PASS " if passed else "FAIL ") + name)
print(f"draws replayed: {n}")
print("replayed card:", got)
print("card on the proof:", proof["award"])
print("VERIFIED" if verified and got == proof["award"] else "NOT VERIFIED")
sys.exit(0 if verified and got == proof["award"] else 1)
Run it with the proof you fetched:
python3 -I verify_pack.py proof.json
For a valid proof it prints one line per check, then the card it recomputed, and exits 0:
PASS pull key comes from the purchase
PASS request is against this version
PASS version root anchored before the request
PASS purchase commitment anchored between as_of and the request
PASS odds were honest at sale
PASS word arrived after the request
PASS recorded card matches the replay
draws replayed: 2
replayed card: 00000000-0000-0000-0000-000000000011
card on the proof: 00000000-0000-0000-0000-000000000011
VERIFIED
If the proof had named a different card, the line FAIL recorded card matches the replay appears and the script ends with NOT VERIFIED. That output is from a test proof, not a real pull.
The script follows the same steps as the proof page in the app. The page is stricter about malformed input: it checks the width of every number and the format of every id before it replays anything, and it caps how many draws it will follow at max_depth. A proof the server publishes is already well formed, so those checks only matter if you edit one by hand.
4. Check the chain yourselfโ
The script takes chain_events from the proof as given. Those are the contract's own logs, but to avoid taking them on trust, read the same values from Arbitrum One. Every record in chain_events comes from a log of the contract at vrf_consumer_address on vrf_network, and you can open the transactions the proof lists (version_anchor_tx_hash, purchase_anchor_tx_hash, request_tx_hash) on Arbiscan.
The contract exposes two read-only getters for this, the same ones the Break guide uses:
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
);
For a Pack, events(version_event_id) returns the version_root and events(purchase_event_id) returns the purchase_commitment. getPull(pull_key) returns the Chainlink word stored for the pull, and its fulfilled flag, not a zero word, says whether the word has arrived. Save this next to verify_pack.py as check_onchain.py, and run it with any Arbitrum One RPC endpoint (it needs pip install web3):
"""Compare a Pack proof with what the contract on Arbitrum One holds.
Usage: python3 -I check_onchain.py proof.json https://<your-arbitrum-one-rpc>
Needs: pip install web3. Keep verify_pack.py in the same folder.
"""
import json
import sys
from web3 import Web3
from verify_pack import (
purchase_commitment,
purchase_event_id,
unavailable_hash,
version_event_id,
version_root,
)
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"}]},
]
def check(proof, contract):
key = proof["target_pull_key"]
rec = proof["records"][key]
root = version_root(rec["version"])
as_of = (rec["as_of"]["block"], rec["as_of"]["log_index"])
commitment = purchase_commitment(root, unavailable_hash(rec["committed_unavailable"]), as_of)
vid = version_event_id(root)
pid = purchase_event_id(rec["purchase_id"])
stored_root = bytes(contract.functions.events(bytes.fromhex(vid)).call()).hex()
stored_commitment = bytes(contract.functions.events(bytes.fromhex(pid)).call()).hex()
event_id, requested, fulfilled, word, _ = contract.functions.getPull(bytes.fromhex(key)).call()
proof_word = next(
e["word"] for e in proof["chain_events"] if e["kind"] == "fulfilled" and e["pull_key"] == key
)
return {
"the contract holds this version root": stored_root == root.hex(),
"the contract holds this purchase commitment": stored_commitment == commitment,
"the pull was requested against this version": bytes(event_id).hex() == vid and requested,
"the contract holds the word the proof uses": fulfilled
and format(word, "064x") == proof_word.lower().removeprefix("0x"),
}
if __name__ == "__main__":
with open(sys.argv[1]) as fh:
proof = json.load(fh)
w3 = Web3(Web3.HTTPProvider(sys.argv[2]))
contract = w3.eth.contract(
address=Web3.to_checksum_address(proof["vrf_consumer_address"]), abi=ABI
)
results = check(proof, contract)
for name, passed in results.items():
print(("PASS " if passed else "FAIL ") + name)
sys.exit(0 if all(results.values()) else 1)
python3 -I check_onchain.py proof.json https://<your-arbitrum-one-rpc>
If all four lines pass, the version root and the purchase commitment the script recomputed are the ones written to the contract, the pull was requested against that version, and the word the replay used is the word the contract holds. The word itself comes from Chainlink: the contract accepts a word only from the VRF coordinator named above, so it cannot be set by anyone else, and PullFulfilled is the log that delivered it. If Chainlink's callback to the contract ever fails, the word is derived from the coordinator's own RandomWordsFulfilled log instead, and the proof's fulfilled entry still carries it.
What the Pack proof coversโ
- Covered: the odds and cards a version was built with, the purchase's commitment, the order of every Pack word on the chain, and the replay of the card.
- Not covered by arithmetic: whether a card that left the pool earlier was honestly unavailable. The proof shows that no card was awarded twice and that every band with odds still had a card available at sale, not why a given card was set aside.
If something is slowโ
If Arbitrum or Chainlink is slow, your pull waits. It is never changed or drawn again.