DEV Community

Cover image for I seal every NBA prediction before tip-off with SHA-256 and a public Git repo
Ugur
Ugur

Posted on

I seal every NBA prediction before tip-off with SHA-256 and a public Git repo

Every sports prediction account has a great record. That's the problem. Once the final score is in, anyone can say they called it, and the easy ways to fake a track record don't need a single lie you can point to: post the calls that came in, quietly delete the ones that didn't, nudge a number after the injury news, or write the "prediction" up after the game.

I build Clutch, an NBA app that puts one number on every game: the chance the side it favours wins. I wanted its record to be something you can check without trusting me. Here's how it works, and the code to verify it yourself.

The three things a real pregame prediction needs

  1. A fingerprint of the numbers, published before tip. If the numbers change later, the fingerprint won't match.
  2. A clock the forecaster can't set. A date I type proves nothing.
  3. A record that keeps the misses. Old entries can't be swapped out or dropped.

None of this needs a blockchain. A hash function, a public Git host and a little discipline cover all three.

1. Seal: hash the night's numbers before the games

Each morning (US Eastern), a scheduled job writes every game of the night still to tip into a JSON file, a seal, with the number the app shows everyone for it:

{
  "format": "clutch-receipt-v1",
  "kind": "seal",
  "slate_date": "2026-10-24",
  "season": "2026-27",
  "sealed_at": "2026-10-24T14:01:23Z",
  "prev_sha256": "…",
  "n_games": 8,
  "games": [
    {
      "game_id": "…",
      "home": "ATL", "away": "HOU",
      "tip_utc": "2026-10-25T00:30:00Z",
      "prediction_id": "…",
      "model_version": "clutch-20260929",
      "home_win_prob": "0.5094",
      "pro_commitment": "5c9f7129eb71e43e…14adc888"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The file's SHA-256 is its fingerprint: 64 hex characters computed from its bytes. Change one digit of one probability and the fingerprint is completely different.

If news moves a number during the day (a star ruled out at 5 PM, say), the job writes a new seal. Nothing is edited in place. The game is graded on the last seal that arrived before its tip. That Hawks–Rockets number above was sealed seven times on Oct 24, drifting from 50.9% to 48.8% for Atlanta; the last one is what got graded (Houston won 121–114, so the call held).

2. The clock: GitHub's, not mine

The seal is pushed to a public repository, uguraltunbas/clutch-receipts.

One subtlety: commit dates are worthless as proof, because whoever makes the commit writes them. What counts is when GitHub received the push. The repository's Activity view lists every push with GitHub's own timestamp, and it lists force pushes too, so rewriting history would show.

On Oct 24 the night's first seal arrived 7 hours 58 minutes before the first tip.

The job pushes with a deploy key that can write to that one repository only.

3. The chain: misses can't be dropped

Every file includes prev_sha256, the fingerprint of the file before it. That makes the repository a hash chain: change or delete any old file and every later file stops matching. index.jsonl lists every file in order with its fingerprint.

Checking the whole chain takes a few lines of Python, standard library only:

import hashlib
import json

prev = None
for line in open("index.jsonl", encoding="ascii"):
    entry = json.loads(line)
    data = open(entry["path"], "rb").read()
    receipt = json.loads(data)
    assert hashlib.sha256(data).hexdigest() == entry["sha256"], entry["path"] + ": fingerprint"
    assert receipt["prev_sha256"] == entry["prev_sha256"] == prev, entry["path"] + ": chain"
    prev = entry["sha256"]
print("chain ok, latest", prev)
Enter fullscreen mode Exit fullscreen mode

Or one file at a time, with what your computer already has:

git clone https://github.com/uguraltunbas/clutch-receipts
cd clutch-receipts
shasum -a 256 2026-27/2026-10-20/seal-182205Z.json   # Linux: sha256sum, Windows: certutil -hashfile
Enter fullscreen mode Exit fullscreen mode

4. Commit-reveal for the numbers I can't publish yet

Some numbers are for paying members before tip (the model's own probability next to the market's). I still want those provably fixed before the game, without giving them away.

That's a classic commit-reveal scheme. Before tip, the seal carries only pro_commitment: the SHA-256 of those numbers plus a long random secret (a salt), serialized canonically. The salt matters: a probability with four decimals has only 10,000 possible values, so without it anyone could find the hidden number by hashing every one.

After the final, a reveal file publishes the numbers and the salt, plus the score and whether the call was right. Anyone can recompute the hash:

import hashlib
import json

def canonical(obj):
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")

def fingerprint(game_id, opening):
    return hashlib.sha256(canonical({
        "format": "clutch-commitment-v1",
        "game_id": game_id,
        "prediction_id": opening["prediction_id"],
        "pro": opening["pro"],
        "salt": opening["salt"],
    })).hexdigest()
Enter fullscreen mode Exit fullscreen mode

If the result is in a seal from before tip, the revealed numbers are the numbers that were sealed. (The canonical serialization, with sorted keys and fixed separators, is what makes the hash reproducible in any language.)

5. Grading, misses included

After each final, the Ledger grades the sealed number: right or wrong, next to ESPN's BPI, the market's price and the dumbest baseline there is, always picking the home team. Games whose number can't be shown to predate tip aren't graded at all, and the Ledger says how many.

The live record is on the Ledger: 24–12 on 36 graded calls as of Oct 25. That's early-season noise, and the Ledger says so. The more useful question is calibration: when Clutch says 70%, does it happen about 70% of the time? In a replay of the last two seasons (2,631 games, each predicted only from games before it), every band landed within 2.4 points of what it said. The accuracy page has the bands, and I wrote up calibration and the Brier score separately.

What a seal doesn't prove

A seal proves when a number was made, not that it was any good. One night, or one week, tells you nothing. That's why the misses have to stay: a record is only worth something if it's complete.

The stack, briefly

  • Python jobs on GitHub Actions write predictions to Postgres (Supabase) and push the receipts.
  • The app (Expo / React Native, iPhone and web) reads only what each tier may see, enforced by row-level security.
  • clutchledger.com is a static site rebuilt from the same tables: a page per game, per team and per matchup.

If you try the verification and something doesn't match, I want to know. That's the whole point. The receipts repo is here, and the step-by-step check is in its VERIFY.md.

Top comments (0)