Skip to content

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

  1. 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.

  2. Frozen at the close

    When entries close, the full entry list is published with its fingerprint, before the number that picks the winners exists.

  3. 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

  1. 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.

  2. 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)