Provably fair draws
How every PENG giveaway and raffle is drawn, and how to check one yourself without trusting us.
Provably fair
The short version
Sealed before you enter
When a giveaway goes live we publish the fingerprint of a secret seed, and the future drand round its draw will use.
Frozen at the close
When entries close, the full entry list is published with its fingerprint, before the number that picks the winners exists.
Picked by drand
A public random number from independent organisations picks the winners. Nobody can know it early, us included, and anyone can re-run a draw.
The seedโs fingerprint is its SHA-256: the seed cannot change after it is published without the hash giving it away. drand is run by the League of Entropy, independent universities and companies, and signs a new random number every 3 seconds.
What is published, and when
Each value is published at the moment it can no longer change who wins.
It goes live
SHA-256 of the 32-byte secret seed, and the drand round the draw will use.
The round is the first one 2 minutes after the close, so it is fixed before anyone enters.
Entries close
The entry list (one line per entry: its tag and its tickets) and the listโs SHA-256.
The list is fixed while the round is still in the future, so no entry can be added after seeing the number.
The round is out
The beaconโs randomness and signature, the seed itself, and the winners with the ticket each drew.
Everything needed to re-run the draw is now public.
If the close is moved later, or the seal runs late because our server was down, the draw moves to a later round, which is still in the future when the list is sealed. Each giveaway page says when that happened.
The draw, exactly
Every ticket is equally likely, and an account wins at most one prize: once drawn, its entry leaves the pool. Prizes are awarded in draw order, first prize first.
inputs seed 32 bytes, revealed after the draw (SHA-256 = the commitment)
randomness the drand round's randomness, 64 hex characters
entries the sealed list, in order: "TAG TICKETS" per line
for draw i = 0, 1, 2, ...
pool = entries not drawn yet, in list order
total = the pool's tickets added up
for attempt = 0, 1, ...
mac = HMAC-SHA256(key = seed, message = "<randomness>:<i>:<attempt>")
x = first 8 bytes of mac, as an unsigned big-endian integer
stop when x < 2^64 - (2^64 mod total) (no ticket is favoured)
t = x mod total
winner = the pool entry whose run of tickets contains t
draw i awards prize i + 1; the winner leaves the pool
A forfeited prize is redrawn at the next i, from whoever is left.The beacon is drandโs quicknet chain (52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971), a round every 3 seconds, signed under the networkโs public key. Our server checks every beaconโs signature before drawing with it.
Check a draw yourself
In one click
On any drawn giveaway, press โCheck this draw in your browserโ. It fetches the beacon from drand itself, verifies its signature, checks the seed and the entry list against their hashes, and re-runs the draw.
By hand
Each giveaway publishes its full record at /giveaways/<address>/fairness.json and its entry list at /giveaways/<address>/entries.txt, whose SHA-256 is the published list hash (sha256sum entries.txt). The beacon is at https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/public/<round>, and drandโs own clients can verify its signature. This Python re-runs a draw with nothing but the standard library:
import hashlib, hmac, json, urllib.request slug = "your-giveaway" # the end of the giveaway's address url = f"https://pengservices.com/giveaways/{slug}/fairness.json" record = json.load(urllib.request.urlopen(url)) seed = bytes.fromhex(record["draw"]["serverSeed"]) randomness = record["drand"]["randomness"] text = record["entries"]["text"] entries = [(tag, int(n)) for tag, n in (line.split(" ") for line in text.split("\n"))] assert hashlib.sha256(seed).hexdigest() == record["commitment"]["serverSeedHash"] assert hashlib.sha256(text.encode()).hexdigest() == record["entries"]["sha256"] start, running = {}, 0 for tag, n in entries: start[tag], running = running, running + n taken = set() for i in range(len(record["draw"]["winners"])): pool = [(tag, n) for tag, n in entries if tag not in taken] total = sum(n for _, n in pool) attempt = 0 while True: message = f"{randomness}:{i}:{attempt}".encode() x = int.from_bytes(hmac.new(seed, message, hashlib.sha256).digest()[:8], "big") if x < 2**64 - (2**64 % total): break attempt += 1 t = x % total for tag, n in pool: if t < n: break t -= n print(f"draw {i}: entry {tag}, ticket {start[tag] + t}") taken.add(tag)

